diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5b9462f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Create a report to help us improve + +--- + +**Describe the Bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Instantiate RAD framework +2. Configure RAD framework with following parameters '....' +3. Play media file that has following RAD metadata '....' +4. Other steps + +**Actual Behavior** +A clear and concise description of what actual behavior is. + +**Expected Behavior** +A clear and concise description of what you expected to happen. + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Version [e.g. Version 1.0 Build 1] + +**Additional Context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..eb8c4a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Please suggest ideas, questions or alternate implementation suggestions here. Remote Audio Data is meant to be a collaborative effort and we hope to foster that dialogue. + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional Context** +Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbf66bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +build/ +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +## Other +*.moved-aside +*.xcuserstate + +## Obj-C/Swift specific +*.hmap +*.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/ +.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/ + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +Carthage + +# 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://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..fa9ca8d --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,4 @@ +excluded: + - Carthage + - Pods + - Demo \ No newline at end of file diff --git a/Cartfile b/Cartfile new file mode 100644 index 0000000..b560b2e --- /dev/null +++ b/Cartfile @@ -0,0 +1,2 @@ +github "AliSoftware/OHHTTPStubs" "6.1.0" +github "ashleymills/Reachability.swift" "v4.3.0" \ No newline at end of file diff --git a/Cartfile.resolved b/Cartfile.resolved new file mode 100644 index 0000000..3977662 --- /dev/null +++ b/Cartfile.resolved @@ -0,0 +1,2 @@ +github "AliSoftware/OHHTTPStubs" "6.1.0" +github "ashleymills/Reachability.swift" "v4.3.0" diff --git a/Demo/RAD-iOSDemo.xcodeproj/project.pbxproj b/Demo/RAD-iOSDemo.xcodeproj/project.pbxproj new file mode 100644 index 0000000..42cb961 --- /dev/null +++ b/Demo/RAD-iOSDemo.xcodeproj/project.pbxproj @@ -0,0 +1,469 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + FF6CE84921942B1B002B2B95 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6CE84821942B1B002B2B95 /* AppDelegate.swift */; }; + FF6CE84B21942B1B002B2B95 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6CE84A21942B1B002B2B95 /* ViewController.swift */; }; + FF6CE84E21942B1B002B2B95 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FF6CE84C21942B1B002B2B95 /* Main.storyboard */; }; + FF6CE85021942B1C002B2B95 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FF6CE84F21942B1C002B2B95 /* Assets.xcassets */; }; + FF6CE85321942B1C002B2B95 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FF6CE85121942B1C002B2B95 /* LaunchScreen.storyboard */; }; + FFC031BF219C1AFC006A29B6 /* SampleFile.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = FFC031BE219C1AFC006A29B6 /* SampleFile.mp3 */; }; + FFC031D3219C342D006A29B6 /* Reachability.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FFC031D1219C340E006A29B6 /* Reachability.framework */; }; + FFC031D4219C342D006A29B6 /* Reachability.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FFC031D1219C340E006A29B6 /* Reachability.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + FFC031DE219C343A006A29B6 /* RAD.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FFC031DB219C3432006A29B6 /* RAD.framework */; }; + FFC031DF219C343A006A29B6 /* RAD.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FFC031DB219C3432006A29B6 /* RAD.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + FFC031DA219C3432006A29B6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FFC031D5219C3432006A29B6 /* RAD.xcodeproj */; + proxyType = 2; + remoteGlobalIDString = FFFA0CA320AF0BEA007D1F9C; + remoteInfo = RAD; + }; + FFC031DC219C3432006A29B6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FFC031D5219C3432006A29B6 /* RAD.xcodeproj */; + proxyType = 2; + remoteGlobalIDString = FFFA0CAC20AF0BEA007D1F9C; + remoteInfo = RADTests; + }; + FFC031E0219C343A006A29B6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FFC031D5219C3432006A29B6 /* RAD.xcodeproj */; + proxyType = 1; + remoteGlobalIDString = FFFA0CA220AF0BEA007D1F9C; + remoteInfo = RAD; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + FF6CE86721942C37002B2B95 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + FFC031DF219C343A006A29B6 /* RAD.framework in Embed Frameworks */, + FFC031D4219C342D006A29B6 /* Reachability.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + FF6CE84521942B1B002B2B95 /* RAD-iOSDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "RAD-iOSDemo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + FF6CE84821942B1B002B2B95 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + FF6CE84A21942B1B002B2B95 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + FF6CE84D21942B1B002B2B95 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + FF6CE84F21942B1C002B2B95 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + FF6CE85221942B1C002B2B95 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + FF6CE85421942B1C002B2B95 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FFC031BE219C1AFC006A29B6 /* SampleFile.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = SampleFile.mp3; sourceTree = ""; }; + FFC031D1219C340E006A29B6 /* Reachability.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Reachability.framework; path = ../../Carthage/Build/iOS/Reachability.framework; sourceTree = ""; }; + FFC031D5219C3432006A29B6 /* RAD.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RAD.xcodeproj; path = ../RAD.xcodeproj; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + FF6CE84221942B1B002B2B95 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FFC031DE219C343A006A29B6 /* RAD.framework in Frameworks */, + FFC031D3219C342D006A29B6 /* Reachability.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + FF6CE83C21942B1B002B2B95 = { + isa = PBXGroup; + children = ( + FFC031D5219C3432006A29B6 /* RAD.xcodeproj */, + FF6CE84721942B1B002B2B95 /* RAD-iOSDemo */, + FF6CE84621942B1B002B2B95 /* Products */, + ); + sourceTree = ""; + }; + FF6CE84621942B1B002B2B95 /* Products */ = { + isa = PBXGroup; + children = ( + FF6CE84521942B1B002B2B95 /* RAD-iOSDemo.app */, + ); + name = Products; + sourceTree = ""; + }; + FF6CE84721942B1B002B2B95 /* RAD-iOSDemo */ = { + isa = PBXGroup; + children = ( + FF6CE84821942B1B002B2B95 /* AppDelegate.swift */, + FF6CE84A21942B1B002B2B95 /* ViewController.swift */, + FF6CE84C21942B1B002B2B95 /* Main.storyboard */, + FF6CE84F21942B1C002B2B95 /* Assets.xcassets */, + FF6CE85121942B1C002B2B95 /* LaunchScreen.storyboard */, + FF6CE85421942B1C002B2B95 /* Info.plist */, + FFC031A8219C1923006A29B6 /* Frameworks */, + FFC031BD219C1AE0006A29B6 /* Resources */, + ); + path = "RAD-iOSDemo"; + sourceTree = ""; + }; + FFC031A8219C1923006A29B6 /* Frameworks */ = { + isa = PBXGroup; + children = ( + FFC031D1219C340E006A29B6 /* Reachability.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + FFC031BD219C1AE0006A29B6 /* Resources */ = { + isa = PBXGroup; + children = ( + FFC031BE219C1AFC006A29B6 /* SampleFile.mp3 */, + ); + name = Resources; + sourceTree = ""; + }; + FFC031D6219C3432006A29B6 /* Products */ = { + isa = PBXGroup; + children = ( + FFC031DB219C3432006A29B6 /* RAD.framework */, + FFC031DD219C3432006A29B6 /* RADTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + FF6CE84421942B1B002B2B95 /* RAD-iOSDemo */ = { + isa = PBXNativeTarget; + buildConfigurationList = FF6CE85721942B1D002B2B95 /* Build configuration list for PBXNativeTarget "RAD-iOSDemo" */; + buildPhases = ( + FF6CE84121942B1B002B2B95 /* Sources */, + FF6CE84221942B1B002B2B95 /* Frameworks */, + FF6CE84321942B1B002B2B95 /* Resources */, + FF6CE86721942C37002B2B95 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + FFC031E1219C343A006A29B6 /* PBXTargetDependency */, + ); + name = "RAD-iOSDemo"; + productName = "RAD-iOSDemo"; + productReference = FF6CE84521942B1B002B2B95 /* RAD-iOSDemo.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + FF6CE83D21942B1B002B2B95 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1010; + LastUpgradeCheck = 1010; + ORGANIZATIONNAME = NPR; + TargetAttributes = { + FF6CE84421942B1B002B2B95 = { + CreatedOnToolsVersion = 10.1; + SystemCapabilities = { + com.apple.BackgroundModes = { + enabled = 1; + }; + }; + }; + }; + }; + buildConfigurationList = FF6CE84021942B1B002B2B95 /* Build configuration list for PBXProject "RAD-iOSDemo" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = FF6CE83C21942B1B002B2B95; + productRefGroup = FF6CE84621942B1B002B2B95 /* Products */; + projectDirPath = ""; + projectReferences = ( + { + ProductGroup = FFC031D6219C3432006A29B6 /* Products */; + ProjectRef = FFC031D5219C3432006A29B6 /* RAD.xcodeproj */; + }, + ); + projectRoot = ""; + targets = ( + FF6CE84421942B1B002B2B95 /* RAD-iOSDemo */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXReferenceProxy section */ + FFC031DB219C3432006A29B6 /* RAD.framework */ = { + isa = PBXReferenceProxy; + fileType = wrapper.framework; + path = RAD.framework; + remoteRef = FFC031DA219C3432006A29B6 /* PBXContainerItemProxy */; + sourceTree = BUILT_PRODUCTS_DIR; + }; + FFC031DD219C3432006A29B6 /* RADTests.xctest */ = { + isa = PBXReferenceProxy; + fileType = wrapper.cfbundle; + path = RADTests.xctest; + remoteRef = FFC031DC219C3432006A29B6 /* PBXContainerItemProxy */; + sourceTree = BUILT_PRODUCTS_DIR; + }; +/* End PBXReferenceProxy section */ + +/* Begin PBXResourcesBuildPhase section */ + FF6CE84321942B1B002B2B95 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FF6CE85321942B1C002B2B95 /* LaunchScreen.storyboard in Resources */, + FF6CE85021942B1C002B2B95 /* Assets.xcassets in Resources */, + FFC031BF219C1AFC006A29B6 /* SampleFile.mp3 in Resources */, + FF6CE84E21942B1B002B2B95 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + FF6CE84121942B1B002B2B95 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FF6CE84B21942B1B002B2B95 /* ViewController.swift in Sources */, + FF6CE84921942B1B002B2B95 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + FFC031E1219C343A006A29B6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = RAD; + targetProxy = FFC031E0219C343A006A29B6 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + FF6CE84C21942B1B002B2B95 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + FF6CE84D21942B1B002B2B95 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + FF6CE85121942B1C002B2B95 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + FF6CE85221942B1C002B2B95 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + FF6CE85521942B1C002B2B95 /* 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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_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; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + 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 = 12.1; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + FF6CE85621942B1C002B2B95 /* 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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_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; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + 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 = 12.1; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + FF6CE85821942B1D002B2B95 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; + FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/../Carthage/Build/iOS"; + INFOPLIST_FILE = "RAD-iOSDemo/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "$(PROJECT_DIR)/../Carthage/Build/iOS", + ); + PRODUCT_BUNDLE_IDENTIFIER = "NPR.RAD-iOSDemo"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FF6CE85921942B1D002B2B95 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; + FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/../Carthage/Build/iOS"; + INFOPLIST_FILE = "RAD-iOSDemo/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "$(PROJECT_DIR)/../Carthage/Build/iOS", + ); + PRODUCT_BUNDLE_IDENTIFIER = "NPR.RAD-iOSDemo"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + FF6CE84021942B1B002B2B95 /* Build configuration list for PBXProject "RAD-iOSDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FF6CE85521942B1C002B2B95 /* Debug */, + FF6CE85621942B1C002B2B95 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FF6CE85721942B1D002B2B95 /* Build configuration list for PBXNativeTarget "RAD-iOSDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FF6CE85821942B1D002B2B95 /* Debug */, + FF6CE85921942B1D002B2B95 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = FF6CE83D21942B1B002B2B95 /* Project object */; +} diff --git a/Demo/RAD-iOSDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Demo/RAD-iOSDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..32a929d --- /dev/null +++ b/Demo/RAD-iOSDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Demo/RAD-iOSDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Demo/RAD-iOSDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Demo/RAD-iOSDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Demo/RAD-iOSDemo.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Demo/RAD-iOSDemo.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Demo/RAD-iOSDemo.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/Demo/RAD-iOSDemo/AppDelegate.swift b/Demo/RAD-iOSDemo/AppDelegate.swift new file mode 100644 index 0000000..e46bbe4 --- /dev/null +++ b/Demo/RAD-iOSDemo/AppDelegate.swift @@ -0,0 +1,60 @@ +// +// AppDelegate.swift +// RAD-iOSDemo +// +// Created by David Livadaru on 08/11/2018. +// Copyright © 2018 NPR. All rights reserved. +// + +import UIKit +import RAD + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + private var analytics: Analytics? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + + let configuration = Configuration( + submissionTimeInterval: TimeInterval.minutes(15), + batchSize: 50, + expirationTimeInterval: DateComponents(day: 5), + sessionExpirationTimeInterval: TimeInterval.hours(12), + requestHeaderFields: [:]) + analytics = Analytics(configuration: configuration) + if let viewController = window?.rootViewController as? ViewController { + viewController.analytics = analytics + } + + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + analytics?.performBackgroundFetch(completion: completionHandler) + } +} diff --git a/Demo/RAD-iOSDemo/Assets.xcassets/AppIcon.appiconset/Contents.json b/Demo/RAD-iOSDemo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d8db8d6 --- /dev/null +++ b/Demo/RAD-iOSDemo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Demo/RAD-iOSDemo/Assets.xcassets/Contents.json b/Demo/RAD-iOSDemo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/Demo/RAD-iOSDemo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Demo/RAD-iOSDemo/Base.lproj/LaunchScreen.storyboard b/Demo/RAD-iOSDemo/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..bfa3612 --- /dev/null +++ b/Demo/RAD-iOSDemo/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Demo/RAD-iOSDemo/Base.lproj/Main.storyboard b/Demo/RAD-iOSDemo/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f1bcf38 --- /dev/null +++ b/Demo/RAD-iOSDemo/Base.lproj/Main.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Demo/RAD-iOSDemo/Info.plist b/Demo/RAD-iOSDemo/Info.plist new file mode 100644 index 0000000..d5afed6 --- /dev/null +++ b/Demo/RAD-iOSDemo/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIBackgroundModes + + fetch + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Demo/RAD-iOSDemo/SampleFile.mp3 b/Demo/RAD-iOSDemo/SampleFile.mp3 new file mode 100644 index 0000000..de9458f Binary files /dev/null and b/Demo/RAD-iOSDemo/SampleFile.mp3 differ diff --git a/Demo/RAD-iOSDemo/ViewController.swift b/Demo/RAD-iOSDemo/ViewController.swift new file mode 100644 index 0000000..a06d597 --- /dev/null +++ b/Demo/RAD-iOSDemo/ViewController.swift @@ -0,0 +1,40 @@ +// +// ViewController.swift +// RAD-iOSDemo +// +// Created by David Livadaru on 08/11/2018. +// Copyright © 2018 NPR. All rights reserved. +// + +import UIKit +import AVFoundation +import RAD + +class ViewController: UIViewController { + var analytics: Analytics? + private let player = AVPlayer(playerItem: nil) + + override func viewDidLoad() { + super.viewDidLoad() + // Do any additional setup after loading the view, typically from a nib. + + analytics?.observePlayer(player) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + play() + } + + private func play() { + guard let url = Bundle.main.url( + forResource: "SampleFile", withExtension: "mp3" + ) else { + return + } + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + player.play() + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a6d63b3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,73 @@ +Open Source License for RAD custom tags + +Copyright 2018 NPR + +Licensed under the Apache License, Version 2.0 (the “License”) with the following modification; you may not use this file except in compliance with the License as modified by the addition of Section 10, as follows: + +10. Prohibitions + +When using the Work, you may not (or allow those acting on your behalf to): + +a. Perform an action with the intent of introducing to the Work, any NPR One API, or any other NPR site, application, platform or service any virus, worm, defect, Trojan horse, malware or any item of a destructive nature, or obtaining unauthorized access to any NPR One API or any other NPR site, application, platform or services; + +b. Remove, obscure or alter any NPR terms of service, including the NPR Terms of Use, or any links to or notices of those terms; or + +c. Take any other action prohibited by any NPR terms of service, including the NPR Terms of Use, unless expressly permitted by the License. + +You may obtain a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License with the above modification 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. + + + 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: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and +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 +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 \ No newline at end of file diff --git a/RAD.xcodeproj/project.pbxproj b/RAD.xcodeproj/project.pbxproj new file mode 100644 index 0000000..870f441 --- /dev/null +++ b/RAD.xcodeproj/project.pbxproj @@ -0,0 +1,1983 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + FF00128B21380772008740D0 /* TimeRangeBoundBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF00128A21380772008740D0 /* TimeRangeBoundBuilder.swift */; }; + FF0262A021258F67007DF038 /* Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF02629F21258F67007DF038 /* Operation.swift */; }; + FF0262A221258F83007DF038 /* OutputOperationType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262A121258F83007DF038 /* OutputOperationType.swift */; }; + FF0262A421258F91007DF038 /* OutputOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262A321258F91007DF038 /* OutputOperation.swift */; }; + FF0262A621258FC8007DF038 /* InputOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262A521258FC8007DF038 /* InputOperation.swift */; }; + FF0262A821258FDF007DF038 /* ClosureInputOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262A721258FDF007DF038 /* ClosureInputOperation.swift */; }; + FF0262AA2125900E007DF038 /* ChainOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262A92125900E007DF038 /* ChainOperation.swift */; }; + FF0262AC2125903B007DF038 /* InputError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262AB2125903B007DF038 /* InputError.swift */; }; + FF0262AF21259053007DF038 /* OutputError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262AE21259053007DF038 /* OutputError.swift */; }; + FF0262B3212590D4007DF038 /* SaveContextOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262B2212590D4007DF038 /* SaveContextOperation.swift */; }; + FF0262B6212595E7007DF038 /* ConvertTimeRangeOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262B5212595E7007DF038 /* ConvertTimeRangeOperation.swift */; }; + FF0262B82125960B007DF038 /* ItemSessionOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262B72125960B007DF038 /* ItemSessionOperation.swift */; }; + FF0262BA2125ADF4007DF038 /* CreateItemSessionOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262B92125ADF4007DF038 /* CreateItemSessionOperation.swift */; }; + FF0262BC2125AE12007DF038 /* ParseRADPayloadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262BB2125AE12007DF038 /* ParseRADPayloadOperation.swift */; }; + FF0262BE2125AE5D007DF038 /* TimeRangeBound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262BD2125AE5D007DF038 /* TimeRangeBound.swift */; }; + FF0262C02125B2E8007DF038 /* ParseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262BF2125B2E8007DF038 /* ParseError.swift */; }; + FF0262C22125B42B007DF038 /* ParseJSONOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0262C12125B42B007DF038 /* ParseJSONOperation.swift */; }; + FF0263012125DCF6007DF038 /* PrettyJSONOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0263002125DCF6007DF038 /* PrettyJSONOperation.swift */; }; + FF062A64213544310063C5E8 /* Timer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF062A63213544310063C5E8 /* Timer.swift */; }; + FF062A662135533F0063C5E8 /* WeakReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF062A652135533F0063C5E8 /* WeakReference.swift */; }; + FF062A68213553FB0063C5E8 /* WeakReferenceContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF062A67213553FB0063C5E8 /* WeakReferenceContainer.swift */; }; + FF062A6A213573880063C5E8 /* ScheduleDataSend.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF062A69213573880063C5E8 /* ScheduleDataSend.swift */; }; + FF062A6C213575950063C5E8 /* SendDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF062A6B213575950063C5E8 /* SendDataOperation.swift */; }; + FF0ACB3920EE099700B454E4 /* Event+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0ACB3820EE099700B454E4 /* Event+Convenience.swift */; }; + FF0ACB3B20EE09A700B454E4 /* RadMetadata+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0ACB3A20EE09A700B454E4 /* RadMetadata+Convenience.swift */; }; + FF0ACB3D20EE09BF00B454E4 /* Server+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0ACB3C20EE09BF00B454E4 /* Server+Convenience.swift */; }; + FF0ACB4420EE147C00B454E4 /* NSManagedObjectContext+Delete.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0ACB4320EE147C00B454E4 /* NSManagedObjectContext+Delete.swift */; }; + FF0ACB4A20EE4EC900B454E4 /* HttpStatusCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0ACB4920EE4EC900B454E4 /* HttpStatusCode.swift */; }; + FF0ACB4D20EE69F400B454E4 /* HttpStatusCodeList.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0ACB4C20EE69F400B454E4 /* HttpStatusCodeList.swift */; }; + FF0ACB4F20EE6A7F00B454E4 /* HttpStatusCodeMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0ACB4E20EE6A7F00B454E4 /* HttpStatusCodeMapping.swift */; }; + FF0E08342191960D003EA672 /* 80Events2TrackingUrls.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = FF0E08322191960C003EA672 /* 80Events2TrackingUrls.mp3 */; }; + FF0E08352191960D003EA672 /* 60Events2TrackingUrls.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = FF0E08332191960C003EA672 /* 60Events2TrackingUrls.mp3 */; }; + FF0ECF442179C8AC009C5528 /* Roundable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0ECF432179C8AC009C5528 /* Roundable.swift */; }; + FF0ECF472179C8D8009C5528 /* UnitDuration+Roundable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0ECF462179C8D8009C5528 /* UnitDuration+Roundable.swift */; }; + FF0F5A75215A22CD00FEF83F /* TimezonedDate+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0F5A74215A22CD00FEF83F /* TimezonedDate+Convenience.swift */; }; + FF0F5A78215A436900FEF83F /* MetadataRelation+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0F5A77215A436900FEF83F /* MetadataRelation+Convenience.swift */; }; + FF0F5A7A215A4DFB00FEF83F /* SendDataCleanupOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0F5A79215A4DFB00FEF83F /* SendDataCleanupOperation.swift */; }; + FF0F5A7C215A558F00FEF83F /* RangeReplaceableCollection+Operators.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0F5A7B215A558F00FEF83F /* RangeReplaceableCollection+Operators.swift */; }; + FF0F5A7E215A5B8000FEF83F /* FoundationOperation+OptionalDependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0F5A7D215A5B8000FEF83F /* FoundationOperation+OptionalDependency.swift */; }; + FF1006632189E47100D54859 /* Configuration+CustomValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1006622189E47100D54859 /* Configuration+CustomValues.swift */; }; + FF1006672189E88500D54859 /* CreateItemSessionOperationTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1006662189E88500D54859 /* CreateItemSessionOperationTestSuite.swift */; }; + FF10A85F2181A60E008DEE36 /* AnalyticsDebugger.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF10A85E2181A60E008DEE36 /* AnalyticsDebugger.swift */; }; + FF10A8622181D4D7008DEE36 /* ObjectConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF10A8612181D4D7008DEE36 /* ObjectConvertible.swift */; }; + FF10A8642181D54E008DEE36 /* DatabaseFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF10A8632181D54E008DEE36 /* DatabaseFetcher.swift */; }; + FF10A8662181D577008DEE36 /* ObjectConversionOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF10A8652181D577008DEE36 /* ObjectConversionOperation.swift */; }; + FF10CDAB214A4CBB0056AA95 /* ItemSessionDeactivateOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF10CDAA214A4CBB0056AA95 /* ItemSessionDeactivateOperation.swift */; }; + FF12407E21884BBC00EA1060 /* FetchOperationTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF12407D21884BBC00EA1060 /* FetchOperationTestSuite.swift */; }; + FF12408021884F3700EA1060 /* ContextTrasnferFetchTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF12407F21884F3700EA1060 /* ContextTrasnferFetchTestSuite.swift */; }; + FF12408221885FB900EA1060 /* TimeRangeControllerTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF12408121885FB900EA1060 /* TimeRangeControllerTestSuite.swift */; }; + FF1240862188937800EA1060 /* 100Events2TrackingUrls.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = FF1240842188937700EA1060 /* 100Events2TrackingUrls.mp3 */; }; + FF1240872188937800EA1060 /* 50Events2TrackingUrls.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = FF1240852188937800EA1060 /* 50Events2TrackingUrls.mp3 */; }; + FF1240892188A80E00EA1060 /* PlayerTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1240882188A80E00EA1060 /* PlayerTestCase.swift */; }; + FF12408B2188A83100EA1060 /* TimeRangeCreationExpectation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF12408A2188A83100EA1060 /* TimeRangeCreationExpectation.swift */; }; + FF12408D2188A83E00EA1060 /* ItemDidPlayToEndExpectation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF12408C2188A83E00EA1060 /* ItemDidPlayToEndExpectation.swift */; }; + FF14B2C020E275BD008B5083 /* StorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF14B2BF20E275BD008B5083 /* StorageTests.swift */; }; + FF14B2C220E27758008B5083 /* Player.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF14B2C120E27758008B5083 /* Player.swift */; }; + FF154FA521805E2600E60011 /* ClosureOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF154FA421805E2600E60011 /* ClosureOperation.swift */; }; + FF154FAB2180A5E300E60011 /* DispatchQueue+Queues.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF154FAA2180A5E300E60011 /* DispatchQueue+Queues.swift */; }; + FF154FAD2180A5F300E60011 /* OperationQueue+Queues.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF154FAC2180A5F300E60011 /* OperationQueue+Queues.swift */; }; + FF154FAF2180B5FF00E60011 /* Object.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF154FAE2180B5FF00E60011 /* Object.swift */; }; + FF154FB12180B60D00E60011 /* ListeningObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF154FB02180B60D00E60011 /* ListeningObserver.swift */; }; + FF154FB32180B61700E60011 /* AnalyticsDebuggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF154FB22180B61700E60011 /* AnalyticsDebuggable.swift */; }; + FF16FA2D20F37F2500ED572E /* DispatchQueue+Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF16FA2C20F37F2500ED572E /* DispatchQueue+Background.swift */; }; + FF16FA2F20F37F3700ED572E /* OperationQueue+Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF16FA2E20F37F3700ED572E /* OperationQueue+Background.swift */; }; + FF16FA3120F3897400ED572E /* URLSessionConfiguration+Configurations.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF16FA3020F3897400ED572E /* URLSessionConfiguration+Configurations.swift */; }; + FF1A69EC219AE1D500D735F6 /* SentTimezonedDatePredicateOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1A69EB219AE1D500D735F6 /* SentTimezonedDatePredicateOperation.swift */; }; + FF2193ED217F0314003417FF /* OperationIsReadyTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2193EC217F0314003417FF /* OperationIsReadyTestCase.swift */; }; + FF2193EF217F0528003417FF /* OperationIsExecutingTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2193EE217F0528003417FF /* OperationIsExecutingTestCase.swift */; }; + FF2193F1217F07E7003417FF /* OperationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2193F0217F07E7003417FF /* OperationTestCase.swift */; }; + FF2193F3217F085A003417FF /* OperationIsFinishedTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2193F2217F085A003417FF /* OperationIsFinishedTestCase.swift */; }; + FF2193F5217F088D003417FF /* StandByOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2193F4217F088D003417FF /* StandByOperation.swift */; }; + FF2193F7217F08AB003417FF /* PlainOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2193F6217F08AB003417FF /* PlainOperation.swift */; }; + FF2193F9217F1694003417FF /* KVOExpectation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2193F8217F1694003417FF /* KVOExpectation.swift */; }; + FF2193FC217F1718003417FF /* OperationIsAsyncTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2193FB217F1718003417FF /* OperationIsAsyncTestCase.swift */; }; + FF2193FE217F18C1003417FF /* OperationIsCancelled.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2193FD217F18C1003417FF /* OperationIsCancelled.swift */; }; + FF219437217F42CC003417FF /* InputOperationTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF219436217F42CC003417FF /* InputOperationTestSuite.swift */; }; + FF219439217F46EC003417FF /* ClosureInputOperationTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF219438217F46EC003417FF /* ClosureInputOperationTestSuite.swift */; }; + FF21943B217F491C003417FF /* AsyncClosureInputOperationTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF21943A217F491C003417FF /* AsyncClosureInputOperationTestSuite.swift */; }; + FF21943D217F4991003417FF /* OutputOperationTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF21943C217F4991003417FF /* OutputOperationTestSuite.swift */; }; + FF21943F217F4D5D003417FF /* ChainOperationTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF21943E217F4D5D003417FF /* ChainOperationTestSuite.swift */; }; + FF219441217F4DAB003417FF /* BooleanOutputOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF219440217F4DAB003417FF /* BooleanOutputOperation.swift */; }; + FF219443217F4DC0003417FF /* ErrorOutputOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF219442217F4DC0003417FF /* ErrorOutputOperation.swift */; }; + FF219446217F4F80003417FF /* ReverseBooleanOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF219445217F4F80003417FF /* ReverseBooleanOperation.swift */; }; + FF21944A217F56B8003417FF /* WaitOperationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF219449217F56B8003417FF /* WaitOperationTestCase.swift */; }; + FF21944C217F59CD003417FF /* NextScheduleOperationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF21944B217F59CD003417FF /* NextScheduleOperationTestCase.swift */; }; + FF21944E217F60A9003417FF /* AggregateOperationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF21944D217F60A9003417FF /* AggregateOperationTestCase.swift */; }; + FF219451217F626F003417FF /* ParseJSONOperationTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF219450217F626F003417FF /* ParseJSONOperationTestSuite.swift */; }; + FF219453217F63D0003417FF /* ParseRADPayloadOperationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF219452217F63D0003417FF /* ParseRADPayloadOperationTestCase.swift */; }; + FF2240CE218B269300F749EE /* ItemSessionRangesTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2240CD218B269300F749EE /* ItemSessionRangesTestCase.swift */; }; + FF2240D0218B278100F749EE /* RADExtractionTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2240CF218B278100F749EE /* RADExtractionTestCase.swift */; }; + FF2397AB20EA6AE6002C9A82 /* RadMetadata+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2397A220EA6AE5002C9A82 /* RadMetadata+CoreDataProperties.swift */; }; + FF2397AD20EA6AE6002C9A82 /* Event+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2397A420EA6AE5002C9A82 /* Event+CoreDataClass.swift */; }; + FF2397AE20EA6AE6002C9A82 /* Event+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2397A520EA6AE5002C9A82 /* Event+CoreDataProperties.swift */; }; + FF2397B120EA6AE6002C9A82 /* GenericObjectContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2397A820EA6AE5002C9A82 /* GenericObjectContainer.swift */; }; + FF2397B220EA6AE6002C9A82 /* RadMetadata+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2397A920EA6AE5002C9A82 /* RadMetadata+CoreDataClass.swift */; }; + FF243093214F8C3A00AA9C4F /* EmptyObjectBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF243092214F8C3A00AA9C4F /* EmptyObjectBuilder.swift */; }; + FF243095214F980600AA9C4F /* ProcessItemCleanupOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF243094214F980600AA9C4F /* ProcessItemCleanupOperation.swift */; }; + FF243097214F982E00AA9C4F /* ExpiredSessionIDBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF243096214F982E00AA9C4F /* ExpiredSessionIDBuilder.swift */; }; + FF243099214F984F00AA9C4F /* OldItemSessionIDOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF243098214F984F00AA9C4F /* OldItemSessionIDOperation.swift */; }; + FF24309E214FB94500AA9C4F /* CreateValidSessionPredicateOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF24309D214FB94500AA9C4F /* CreateValidSessionPredicateOperation.swift */; }; + FF25BB48212C07E6000B2ECF /* FilterResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF25BB47212C07E6000B2ECF /* FilterResult.swift */; }; + FF2CD67C20D11D5700057CBB /* EventRegistration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2CD67B20D11D5700057CBB /* EventRegistration.swift */; }; + FF2E28DC214BD05700360149 /* NSString+MD5.h in Headers */ = {isa = PBXBuildFile; fileRef = FF2E28DA214BD05700360149 /* NSString+MD5.h */; settings = {ATTRIBUTES = (Public, ); }; }; + FF2E28DD214BD05700360149 /* NSString+MD5.m in Sources */ = {isa = PBXBuildFile; fileRef = FF2E28DB214BD05700360149 /* NSString+MD5.m */; }; + FF2EBFDB2183030100BDDE4C /* MetadataRelation+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFCF2183030100BDDE4C /* MetadataRelation+CoreDataClass.swift */; }; + FF2EBFDC2183030100BDDE4C /* MetadataRelation+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFD02183030100BDDE4C /* MetadataRelation+CoreDataProperties.swift */; }; + FF2EBFDD2183030100BDDE4C /* TimezonedDate+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFD12183030100BDDE4C /* TimezonedDate+CoreDataClass.swift */; }; + FF2EBFDE2183030100BDDE4C /* TimezonedDate+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFD22183030100BDDE4C /* TimezonedDate+CoreDataProperties.swift */; }; + FF2EBFDF2183030100BDDE4C /* Rad+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFD32183030100BDDE4C /* Rad+CoreDataClass.swift */; }; + FF2EBFE02183030100BDDE4C /* Rad+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFD42183030100BDDE4C /* Rad+CoreDataProperties.swift */; }; + FF2EBFE12183030100BDDE4C /* ItemSession+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFD52183030100BDDE4C /* ItemSession+CoreDataClass.swift */; }; + FF2EBFE22183030100BDDE4C /* ItemSession+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFD62183030100BDDE4C /* ItemSession+CoreDataProperties.swift */; }; + FF2EBFE32183030100BDDE4C /* Range+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFD72183030100BDDE4C /* Range+CoreDataClass.swift */; }; + FF2EBFE42183030100BDDE4C /* Range+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFD82183030100BDDE4C /* Range+CoreDataProperties.swift */; }; + FF2EBFE52183030100BDDE4C /* ItemSessionID+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFD92183030100BDDE4C /* ItemSessionID+CoreDataClass.swift */; }; + FF2EBFE62183030100BDDE4C /* ItemSessionID+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFDA2183030100BDDE4C /* ItemSessionID+CoreDataProperties.swift */; }; + FF2EBFEA2183457B00BDDE4C /* WeakReferenceTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFE92183457B00BDDE4C /* WeakReferenceTestCase.swift */; }; + FF2EBFEC2183464F00BDDE4C /* WeakReferenceContainerTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EBFEB2183464F00BDDE4C /* WeakReferenceContainerTestCase.swift */; }; + FF380EF4217DBBAD0029A4D2 /* DateComponentsTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380EF3217DBBAD0029A4D2 /* DateComponentsTestCase.swift */; }; + FF380EF7217DC7E70029A4D2 /* XCTest+OptionalEqual.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380EF6217DC7E70029A4D2 /* XCTest+OptionalEqual.swift */; }; + FF380EF9217DC80C0029A4D2 /* XCTest+MessageOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380EF8217DC80C0029A4D2 /* XCTest+MessageOption.swift */; }; + FF380EFB217DC8550029A4D2 /* Inversable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380EFA217DC8550029A4D2 /* Inversable.swift */; }; + FF380EFD217DC8650029A4D2 /* Int+Inversible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380EFC217DC8650029A4D2 /* Int+Inversible.swift */; }; + FF380F01217DD7B20029A4D2 /* DictionaryRawRepresentableTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380F00217DD7B20029A4D2 /* DictionaryRawRepresentableTestCase.swift */; }; + FF380F03217DD93E0029A4D2 /* DispatchQueueTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380F02217DD93E0029A4D2 /* DispatchQueueTestCase.swift */; }; + FF380F05217DDCF50029A4D2 /* DoubleExpectationTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380F04217DDCF50029A4D2 /* DoubleExpectationTestSuite.swift */; }; + FF380F07217DDFAE0029A4D2 /* TimeIntervalTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380F06217DDFAE0029A4D2 /* TimeIntervalTestSuite.swift */; }; + FF380F09217DE2510029A4D2 /* SwiftMathFunctionTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380F08217DE2510029A4D2 /* SwiftMathFunctionTestSuite.swift */; }; + FF380F0B217DE9B00029A4D2 /* UrlRequestTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380F0A217DE9B00029A4D2 /* UrlRequestTestCase.swift */; }; + FF380F0D217DEB870029A4D2 /* RangeReplaceableCollectionTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380F0C217DEB870029A4D2 /* RangeReplaceableCollectionTestSuite.swift */; }; + FF380F0F217DEE0B0029A4D2 /* FoundationOperationDependencyTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380F0E217DEE0B0029A4D2 /* FoundationOperationDependencyTestCase.swift */; }; + FF380F11217DF3BB0029A4D2 /* NSStringMD5TestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF380F10217DF3BB0029A4D2 /* NSStringMD5TestSuite.swift */; }; + FF380F13217DF7270029A4D2 /* MD5_JSON.json in Resources */ = {isa = PBXBuildFile; fileRef = FF380F12217DF7270029A4D2 /* MD5_JSON.json */; }; + FF48622020CAA7B700B996C6 /* CMTimeFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF48621F20CAA7B700B996C6 /* CMTimeFormatterTests.swift */; }; + FF48622220CAAACA00B996C6 /* TimeComponentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF48622120CAAACA00B996C6 /* TimeComponentsTests.swift */; }; + FF48622520CAB10700B996C6 /* Double+Equality.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF48622420CAB10700B996C6 /* Double+Equality.swift */; }; + FF488F0A21343CE100851FF1 /* DispatchQueue+Execution.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF488F0921343CE100851FF1 /* DispatchQueue+Execution.swift */; }; + FF4A048921906369003A466B /* SimpleTestCaseFullScheduling.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4A048821906369003A466B /* SimpleTestCaseFullScheduling.swift */; }; + FF4A048D21909071003A466B /* ItemSessionIDUnlockedTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4A048C21909071003A466B /* ItemSessionIDUnlockedTestSuite.swift */; }; + FF4A048F2190908E003A466B /* RadIDUnlockedTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4A048E2190908E003A466B /* RadIDUnlockedTestSuite.swift */; }; + FF4A3FB6212AB56900970A97 /* ProcessItemSessionsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4A3FB5212AB56900970A97 /* ProcessItemSessionsOperation.swift */; }; + FF4A3FB8212AB58400970A97 /* RADPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4A3FB7212AB58400970A97 /* RADPayload.swift */; }; + FF4A3FBA212AB59600970A97 /* ParseRADObjectsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4A3FB9212AB59600970A97 /* ParseRADObjectsOperation.swift */; }; + FF4A3FBC212ABA3600970A97 /* Range+CMTimeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4A3FBB212ABA3600970A97 /* Range+CMTimeExtension.swift */; }; + FF4A3FBE212AD5A900970A97 /* DeleteOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4A3FBD212AD5A900970A97 /* DeleteOperation.swift */; }; + FF4B389C213D58760026148C /* ResponseCheckOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B389B213D58760026148C /* ResponseCheckOperation.swift */; }; + FF4B389E213D58850026148C /* NetworkOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B389D213D58850026148C /* NetworkOperation.swift */; }; + FF4B38A0213D58990026148C /* ConvertBatchOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B389F213D58990026148C /* ConvertBatchOperation.swift */; }; + FF4B38A2213D58AC0026148C /* ProcessBatchesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B38A1213D58AC0026148C /* ProcessBatchesOperation.swift */; }; + FF4B38A4213D58BA0026148C /* AggregateOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B38A3213D58BA0026148C /* AggregateOperation.swift */; }; + FF4B38A6213D58CF0026148C /* BatchEventsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B38A5213D58CF0026148C /* BatchEventsOperation.swift */; }; + FF4B38A8213D5A880026148C /* NetworkResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B38A7213D5A880026148C /* NetworkResult.swift */; }; + FF4B38AA213D5A910026148C /* ConversionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B38A9213D5A910026148C /* ConversionResult.swift */; }; + FF5C613221523FBC004C94C2 /* LockRadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5C613121523FBC004C94C2 /* LockRadOperation.swift */; }; + FF5C613421525C61004C94C2 /* UnlockObjectsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5C613321525C61004C94C2 /* UnlockObjectsOperation.swift */; }; + FF5C613621525FBD004C94C2 /* UnlockedRadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5C613521525FBD004C94C2 /* UnlockedRadOperation.swift */; }; + FF5D6F7A218C561100A3B8FF /* small_audio_file.m4a in Resources */ = {isa = PBXBuildFile; fileRef = FF5D6F79218C561000A3B8FF /* small_audio_file.m4a */; }; + FF5D6F7C218C561700A3B8FF /* smallFile_10Events.json in Resources */ = {isa = PBXBuildFile; fileRef = FF5D6F7B218C561700A3B8FF /* smallFile_10Events.json */; }; + FF5D6F7E218C57EB00A3B8FF /* RangeCreationExpectationBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5D6F7D218C57EB00A3B8FF /* RangeCreationExpectationBuilder.swift */; }; + FF5D6F80218C57FA00A3B8FF /* TimeRangeControllerEndOfFileTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5D6F7F218C57FA00A3B8FF /* TimeRangeControllerEndOfFileTestCase.swift */; }; + FF5D6F82218C6A4800A3B8FF /* 1_000_Events.json in Resources */ = {isa = PBXBuildFile; fileRef = FF5D6F81218C6A4800A3B8FF /* 1_000_Events.json */; }; + FF5D6F84218C6A5100A3B8FF /* 1_000Events2TrackingUrls.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = FF5D6F83218C6A5100A3B8FF /* 1_000Events2TrackingUrls.mp3 */; }; + FF5D6F88218C80E000A3B8FF /* ItemSessionInactiveTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5D6F87218C80E000A3B8FF /* ItemSessionInactiveTestSuite.swift */; }; + FF60BD172153D1370001595C /* AsyncClosureInputOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF60BD162153D1370001595C /* AsyncClosureInputOperation.swift */; }; + FF6CEF1920D7A887000661B1 /* ParsingTests_eventTimeWrongFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = FF6CEF0C20D7A887000661B1 /* ParsingTests_eventTimeWrongFormat.json */; }; + FF6CEF1A20D7A887000661B1 /* ParsingTests_missingTimeProperty.json in Resources */ = {isa = PBXBuildFile; fileRef = FF6CEF0D20D7A887000661B1 /* ParsingTests_missingTimeProperty.json */; }; + FF6CEF1B20D7A887000661B1 /* ParsingTests_extraProperties.json in Resources */ = {isa = PBXBuildFile; fileRef = FF6CEF0E20D7A887000661B1 /* ParsingTests_extraProperties.json */; }; + FF6CEF1C20D7A887000661B1 /* ParsingTests_noURL.json in Resources */ = {isa = PBXBuildFile; fileRef = FF6CEF0F20D7A887000661B1 /* ParsingTests_noURL.json */; }; + FF6CEF1D20D7A887000661B1 /* ParsingTests_mispelledProperty.json in Resources */ = {isa = PBXBuildFile; fileRef = FF6CEF1020D7A887000661B1 /* ParsingTests_mispelledProperty.json */; }; + FF6CEF1E20D7A887000661B1 /* no_URL_available.m4a in Resources */ = {isa = PBXBuildFile; fileRef = FF6CEF1220D7A887000661B1 /* no_URL_available.m4a */; }; + FF6CEF1F20D7A887000661B1 /* RAD_events_properties_not_available.m4a in Resources */ = {isa = PBXBuildFile; fileRef = FF6CEF1320D7A887000661B1 /* RAD_events_properties_not_available.m4a */; }; + FF6CEF2020D7A887000661B1 /* RAD_extra_properties.m4a in Resources */ = {isa = PBXBuildFile; fileRef = FF6CEF1420D7A887000661B1 /* RAD_extra_properties.m4a */; }; + FF6CEF2120D7A887000661B1 /* RAD_time_not_available.m4a in Resources */ = {isa = PBXBuildFile; fileRef = FF6CEF1520D7A887000661B1 /* RAD_time_not_available.m4a */; }; + FF6CEF2220D7A887000661B1 /* RAD_time_wrong_format.m4a in Resources */ = {isa = PBXBuildFile; fileRef = FF6CEF1620D7A887000661B1 /* RAD_time_wrong_format.m4a */; }; + FF6CEF2320D7A887000661B1 /* no_RAD_medata.m4a in Resources */ = {isa = PBXBuildFile; fileRef = FF6CEF1720D7A887000661B1 /* no_RAD_medata.m4a */; }; + FF6CEF2420D7A887000661B1 /* RAD_metadata_properties_incorrectly_spelled.m4a in Resources */ = {isa = PBXBuildFile; fileRef = FF6CEF1820D7A887000661B1 /* RAD_metadata_properties_incorrectly_spelled.m4a */; }; + FF6CEF2620D7C2C9000661B1 /* RAD_events.m4a in Resources */ = {isa = PBXBuildFile; fileRef = FF6CEF2520D7C2C9000661B1 /* RAD_events.m4a */; }; + FF6DD31A20EB55D900B898B9 /* Server+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6DD31820EB55D900B898B9 /* Server+CoreDataClass.swift */; }; + FF6DD31B20EB55D900B898B9 /* Server+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6DD31920EB55D900B898B9 /* Server+CoreDataProperties.swift */; }; + FF6DD31E20EB60DF00B898B9 /* NSManagedObjectContext+AsyncFetchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6DD31D20EB60DF00B898B9 /* NSManagedObjectContext+AsyncFetchResult.swift */; }; + FF71BD7720D2406E00125217 /* Double+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF71BD7620D2406E00125217 /* Double+String.swift */; }; + FF71BD7E20D2602200125217 /* GeneratedEventsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF71BD7D20D2602200125217 /* GeneratedEventsTests.swift */; }; + FF71BD8020D2607D00125217 /* Bundle+Framework.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF71BD7F20D2607D00125217 /* Bundle+Framework.swift */; }; + FF71BD8220D260C600125217 /* AnalyticsTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF71BD8120D260C600125217 /* AnalyticsTestCase.swift */; }; + FF7DC1FB20CEB56B0019DD84 /* ParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7DC1FA20CEB56B0019DD84 /* ParsingTests.swift */; }; + FF81E6AD2151394900504060 /* LockSessionOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF81E6AC2151394900504060 /* LockSessionOperation.swift */; }; + FF81E6AF21513B5D00504060 /* ContextFetchOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF81E6AE21513B5D00504060 /* ContextFetchOperation.swift */; }; + FF81E6B121513D6F00504060 /* UnlockedSessionBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF81E6B021513D6F00504060 /* UnlockedSessionBuilder.swift */; }; + FF84B90F2187396900D48755 /* MockPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF84B90E2187396900D48755 /* MockPlayer.swift */; }; + FF84B9122187595800D48755 /* SaveOperationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF84B9112187595800D48755 /* SaveOperationTestCase.swift */; }; + FF9F14272121690200D1C39E /* UnitDuration+Subunits.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9F14262121690200D1C39E /* UnitDuration+Subunits.swift */; }; + FF9F142A2121A90500D1C39E /* TimeRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9F14292121A90500D1C39E /* TimeRange.swift */; }; + FF9F142C2121A95500D1C39E /* TimeRangeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9F142B2121A95500D1C39E /* TimeRangeController.swift */; }; + FF9F14352121B66500D1C39E /* RangeBound+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9F14332121B66400D1C39E /* RangeBound+CoreDataClass.swift */; }; + FF9F14362121B66500D1C39E /* RangeBound+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9F14342121B66400D1C39E /* RangeBound+CoreDataProperties.swift */; }; + FFA0C1A92191E66E00D684AA /* 180Events2TrackingUrls.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = FFA0C1A72191E66E00D684AA /* 180Events2TrackingUrls.mp3 */; }; + FFA0C1AA2191E66E00D684AA /* 240Events2TrackingUrls.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = FFA0C1A82191E66E00D684AA /* 240Events2TrackingUrls.mp3 */; }; + FFA0C1AD2191EC0D00D684AA /* AnalyticsDebuggerExtractPayloadTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA0C1AC2191EC0D00D684AA /* AnalyticsDebuggerExtractPayloadTestCase.swift */; }; + FFA0C1AF2191FEE000D684AA /* AnalyticsTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA0C1AE2191FEE000D684AA /* AnalyticsTestSuite.swift */; }; + FFA0C1B1219207D100D684AA /* MD5Checkable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA0C1B0219207D100D684AA /* MD5Checkable.swift */; }; + FFA9D5142126BD84004EBDD0 /* ContextTransferOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA9D5132126BD84004EBDD0 /* ContextTransferOperation.swift */; }; + FFA9D51B2126FDF8004EBDD0 /* FetchError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA9D51A2126FDF8004EBDD0 /* FetchError.swift */; }; + FFA9D51D2126FE2C004EBDD0 /* FetchOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA9D51C2126FE2C004EBDD0 /* FetchOperation.swift */; }; + FFA9D5232126FF90004EBDD0 /* CreateEmptyObjectPredicateOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA9D5222126FF90004EBDD0 /* CreateEmptyObjectPredicateOperation.swift */; }; + FFA9D5252126FFAF004EBDD0 /* CreateOldEventsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA9D5242126FFAF004EBDD0 /* CreateOldEventsOperation.swift */; }; + FFA9D5282126FFEE004EBDD0 /* FindNextSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA9D5272126FFEE004EBDD0 /* FindNextSchedule.swift */; }; + FFA9D52A2127000A004EBDD0 /* WaitOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA9D5292127000A004EBDD0 /* WaitOperation.swift */; }; + FFA9D52C21270020004EBDD0 /* Scheduling.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA9D52B21270020004EBDD0 /* Scheduling.swift */; }; + FFA9D5302127035B004EBDD0 /* FilterEventsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA9D52F2127035B004EBDD0 /* FilterEventsOperation.swift */; }; + FFBDED9E20F5D3F500F80E61 /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBDED9D20F5D3F500F80E61 /* NetworkService.swift */; }; + FFC031C6219C1C5F006A29B6 /* OHHTTPStubs.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FFC031C5219C1C5F006A29B6 /* OHHTTPStubs.framework */; }; + FFC031C8219C1C64006A29B6 /* Reachability.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FFC031C7219C1C64006A29B6 /* Reachability.framework */; }; + FFC50CA92181D9FB00748BA9 /* Event+ObjectConvertibleExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC50CA82181D9FB00748BA9 /* Event+ObjectConvertibleExtension.swift */; }; + FFC50CAB2181DA2100748BA9 /* ItemSession+ObjectConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC50CAA2181DA2100748BA9 /* ItemSession+ObjectConvertible.swift */; }; + FFC50CAD2181DA3800748BA9 /* ItemSessionID+ObjectConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC50CAC2181DA3800748BA9 /* ItemSessionID+ObjectConvertible.swift */; }; + FFC50CAF2181DA4A00748BA9 /* Rad+ObjectConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC50CAE2181DA4A00748BA9 /* Rad+ObjectConvertible.swift */; }; + FFC50CB12181DA6000748BA9 /* RadMetadata+ObjectConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC50CB02181DA6000748BA9 /* RadMetadata+ObjectConvertible.swift */; }; + FFC50CB32181DA7600748BA9 /* MetadataRelation+ObjectConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC50CB22181DA7600748BA9 /* MetadataRelation+ObjectConvertible.swift */; }; + FFC50CB52181DA8600748BA9 /* Range+ObjectConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC50CB42181DA8600748BA9 /* Range+ObjectConvertible.swift */; }; + FFC50CB72181DAAF00748BA9 /* RangeBound+ObjectConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC50CB62181DAAF00748BA9 /* RangeBound+ObjectConvertible.swift */; }; + FFC50CB92181DAC000748BA9 /* Server+ObjectConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC50CB82181DAC000748BA9 /* Server+ObjectConvertible.swift */; }; + FFC50CBB2181DACF00748BA9 /* TimezonedDate+ObjectConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC50CBA2181DACF00748BA9 /* TimezonedDate+ObjectConvertible.swift */; }; + FFD0D71820F8CEF6009FD005 /* Date+Now.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD0D71720F8CEF6009FD005 /* Date+Now.swift */; }; + FFD0D71A20F8D6B7009FD005 /* DateComponents+NegativeOperator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD0D71920F8D6B7009FD005 /* DateComponents+NegativeOperator.swift */; }; + FFE8B56A20E6599C0038A53B /* NetworkScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE8B56920E6599C0038A53B /* NetworkScheduler.swift */; }; + FFE8B56E20E659DE0038A53B /* RequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE8B56D20E659DE0038A53B /* RequestBuilder.swift */; }; + FFE8B57220E6604B0038A53B /* URLRequest+HttpMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE8B57120E6604B0038A53B /* URLRequest+HttpMethod.swift */; }; + FFF184C120ECAEE4009682D8 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF184C020ECAEE4009682D8 /* Configuration.swift */; }; + FFF184C320ECB0B3009682D8 /* TimeInterval+Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF184C220ECB0B3009682D8 /* TimeInterval+Components.swift */; }; + FFF184C520ECB48C009682D8 /* UserDefaultsKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF184C420ECB48C009682D8 /* UserDefaultsKeys.swift */; }; + FFF184C720ECD44D009682D8 /* Batch.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF184C620ECD44D009682D8 /* Batch.swift */; }; + FFF184C920ECD46B009682D8 /* MetadataGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF184C820ECD46B009682D8 /* MetadataGroup.swift */; }; + FFF184CB20ECD9F1009682D8 /* SwiftMathFunctions+Measurement.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF184CA20ECD9F1009682D8 /* SwiftMathFunctions+Measurement.swift */; }; + FFF285EC20C922CC00BCFF0A /* PlayerObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF285EB20C922CC00BCFF0A /* PlayerObserver.swift */; }; + FFF285F020C9234400BCFF0A /* CMTimeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF285EF20C9234400BCFF0A /* CMTimeFormatter.swift */; }; + FFF285F320C94DF400BCFF0A /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF285F220C94DF400BCFF0A /* JSON.swift */; }; + FFF2860020C94F4900BCFF0A /* Dictionary+RawRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF285FF20C94F4900BCFF0A /* Dictionary+RawRepresentable.swift */; }; + FFF2860220C95AD400BCFF0A /* TimeComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF2860120C95AD400BCFF0A /* TimeComponents.swift */; }; + FFF2860720C95B3300BCFF0A /* CMTimeScale.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF2860620C95B3300BCFF0A /* CMTimeScale.swift */; }; + FFF37B5220C8199900089FF0 /* Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF37B5120C8199900089FF0 /* Analytics.swift */; }; + FFFA0CAD20AF0BEA007D1F9C /* RAD.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FFFA0CA320AF0BEA007D1F9C /* RAD.framework */; }; + FFFA0CB220AF0BEA007D1F9C /* RADTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFA0CB120AF0BEA007D1F9C /* RADTests.swift */; }; + FFFA0CB420AF0BEA007D1F9C /* RAD.h in Headers */ = {isa = PBXBuildFile; fileRef = FFFA0CA620AF0BEA007D1F9C /* RAD.h */; settings = {ATTRIBUTES = (Public, ); }; }; + FFFF629920DA71E700CB1A60 /* RADDatabaseModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FFFF629720DA71E700CB1A60 /* RADDatabaseModel.xcdatamodeld */; }; + FFFF62A720DA7A5D00CB1A60 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFF62A620DA7A5D00CB1A60 /* Storage.swift */; }; + FFFF62AA20DA86B700CB1A60 /* Bundle+Framework.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFF62A920DA86B700CB1A60 /* Bundle+Framework.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + FFFA0CAE20AF0BEA007D1F9C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FFFA0C9A20AF0BEA007D1F9C /* Project object */; + proxyType = 1; + remoteGlobalIDString = FFFA0CA220AF0BEA007D1F9C; + remoteInfo = RAD; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + FF00128A21380772008740D0 /* TimeRangeBoundBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeRangeBoundBuilder.swift; sourceTree = ""; }; + FF02629F21258F67007DF038 /* Operation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Operation.swift; sourceTree = ""; }; + FF0262A121258F83007DF038 /* OutputOperationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputOperationType.swift; sourceTree = ""; }; + FF0262A321258F91007DF038 /* OutputOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputOperation.swift; sourceTree = ""; }; + FF0262A521258FC8007DF038 /* InputOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputOperation.swift; sourceTree = ""; }; + FF0262A721258FDF007DF038 /* ClosureInputOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosureInputOperation.swift; sourceTree = ""; }; + FF0262A92125900E007DF038 /* ChainOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChainOperation.swift; sourceTree = ""; }; + FF0262AB2125903B007DF038 /* InputError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputError.swift; sourceTree = ""; }; + FF0262AE21259053007DF038 /* OutputError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputError.swift; sourceTree = ""; }; + FF0262B2212590D4007DF038 /* SaveContextOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveContextOperation.swift; sourceTree = ""; }; + FF0262B5212595E7007DF038 /* ConvertTimeRangeOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConvertTimeRangeOperation.swift; sourceTree = ""; }; + FF0262B72125960B007DF038 /* ItemSessionOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSessionOperation.swift; sourceTree = ""; }; + FF0262B92125ADF4007DF038 /* CreateItemSessionOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateItemSessionOperation.swift; sourceTree = ""; }; + FF0262BB2125AE12007DF038 /* ParseRADPayloadOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseRADPayloadOperation.swift; sourceTree = ""; }; + FF0262BD2125AE5D007DF038 /* TimeRangeBound.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeRangeBound.swift; sourceTree = ""; }; + FF0262BF2125B2E8007DF038 /* ParseError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseError.swift; sourceTree = ""; }; + FF0262C12125B42B007DF038 /* ParseJSONOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseJSONOperation.swift; sourceTree = ""; }; + FF0263002125DCF6007DF038 /* PrettyJSONOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrettyJSONOperation.swift; sourceTree = ""; }; + FF062A63213544310063C5E8 /* Timer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timer.swift; sourceTree = ""; }; + FF062A652135533F0063C5E8 /* WeakReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakReference.swift; sourceTree = ""; }; + FF062A67213553FB0063C5E8 /* WeakReferenceContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakReferenceContainer.swift; sourceTree = ""; }; + FF062A69213573880063C5E8 /* ScheduleDataSend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleDataSend.swift; sourceTree = ""; }; + FF062A6B213575950063C5E8 /* SendDataOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDataOperation.swift; sourceTree = ""; }; + FF0ACB3820EE099700B454E4 /* Event+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Event+Convenience.swift"; sourceTree = ""; }; + FF0ACB3A20EE09A700B454E4 /* RadMetadata+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RadMetadata+Convenience.swift"; sourceTree = ""; }; + FF0ACB3C20EE09BF00B454E4 /* Server+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Server+Convenience.swift"; sourceTree = ""; }; + FF0ACB4320EE147C00B454E4 /* NSManagedObjectContext+Delete.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Delete.swift"; sourceTree = ""; }; + FF0ACB4920EE4EC900B454E4 /* HttpStatusCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpStatusCode.swift; sourceTree = ""; }; + FF0ACB4C20EE69F400B454E4 /* HttpStatusCodeList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpStatusCodeList.swift; sourceTree = ""; }; + FF0ACB4E20EE6A7F00B454E4 /* HttpStatusCodeMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpStatusCodeMapping.swift; sourceTree = ""; }; + FF0E08322191960C003EA672 /* 80Events2TrackingUrls.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = 80Events2TrackingUrls.mp3; sourceTree = ""; }; + FF0E08332191960C003EA672 /* 60Events2TrackingUrls.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = 60Events2TrackingUrls.mp3; sourceTree = ""; }; + FF0ECF432179C8AC009C5528 /* Roundable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Roundable.swift; sourceTree = ""; }; + FF0ECF462179C8D8009C5528 /* UnitDuration+Roundable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UnitDuration+Roundable.swift"; sourceTree = ""; }; + FF0F5A74215A22CD00FEF83F /* TimezonedDate+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimezonedDate+Convenience.swift"; sourceTree = ""; }; + FF0F5A77215A436900FEF83F /* MetadataRelation+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataRelation+Convenience.swift"; sourceTree = ""; }; + FF0F5A79215A4DFB00FEF83F /* SendDataCleanupOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDataCleanupOperation.swift; sourceTree = ""; }; + FF0F5A7B215A558F00FEF83F /* RangeReplaceableCollection+Operators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RangeReplaceableCollection+Operators.swift"; sourceTree = ""; }; + FF0F5A7D215A5B8000FEF83F /* FoundationOperation+OptionalDependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FoundationOperation+OptionalDependency.swift"; sourceTree = ""; }; + FF1006622189E47100D54859 /* Configuration+CustomValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Configuration+CustomValues.swift"; sourceTree = ""; }; + FF1006662189E88500D54859 /* CreateItemSessionOperationTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateItemSessionOperationTestSuite.swift; sourceTree = ""; }; + FF10A85E2181A60E008DEE36 /* AnalyticsDebugger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsDebugger.swift; sourceTree = ""; }; + FF10A8612181D4D7008DEE36 /* ObjectConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectConvertible.swift; sourceTree = ""; }; + FF10A8632181D54E008DEE36 /* DatabaseFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseFetcher.swift; sourceTree = ""; }; + FF10A8652181D577008DEE36 /* ObjectConversionOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectConversionOperation.swift; sourceTree = ""; }; + FF10CDAA214A4CBB0056AA95 /* ItemSessionDeactivateOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSessionDeactivateOperation.swift; sourceTree = ""; }; + FF12407D21884BBC00EA1060 /* FetchOperationTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchOperationTestSuite.swift; sourceTree = ""; }; + FF12407F21884F3700EA1060 /* ContextTrasnferFetchTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextTrasnferFetchTestSuite.swift; sourceTree = ""; }; + FF12408121885FB900EA1060 /* TimeRangeControllerTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeRangeControllerTestSuite.swift; sourceTree = ""; }; + FF1240842188937700EA1060 /* 100Events2TrackingUrls.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = 100Events2TrackingUrls.mp3; sourceTree = ""; }; + FF1240852188937800EA1060 /* 50Events2TrackingUrls.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = 50Events2TrackingUrls.mp3; sourceTree = ""; }; + FF1240882188A80E00EA1060 /* PlayerTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerTestCase.swift; sourceTree = ""; }; + FF12408A2188A83100EA1060 /* TimeRangeCreationExpectation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeRangeCreationExpectation.swift; sourceTree = ""; }; + FF12408C2188A83E00EA1060 /* ItemDidPlayToEndExpectation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDidPlayToEndExpectation.swift; sourceTree = ""; }; + FF14B2BF20E275BD008B5083 /* StorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageTests.swift; sourceTree = ""; }; + FF14B2C120E27758008B5083 /* Player.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = ""; }; + FF154FA421805E2600E60011 /* ClosureOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosureOperation.swift; sourceTree = ""; }; + FF154FAA2180A5E300E60011 /* DispatchQueue+Queues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Queues.swift"; sourceTree = ""; }; + FF154FAC2180A5F300E60011 /* OperationQueue+Queues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperationQueue+Queues.swift"; sourceTree = ""; }; + FF154FAE2180B5FF00E60011 /* Object.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Object.swift; sourceTree = ""; }; + FF154FB02180B60D00E60011 /* ListeningObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningObserver.swift; sourceTree = ""; }; + FF154FB22180B61700E60011 /* AnalyticsDebuggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsDebuggable.swift; sourceTree = ""; }; + FF16FA2C20F37F2500ED572E /* DispatchQueue+Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Background.swift"; sourceTree = ""; }; + FF16FA2E20F37F3700ED572E /* OperationQueue+Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperationQueue+Background.swift"; sourceTree = ""; }; + FF16FA3020F3897400ED572E /* URLSessionConfiguration+Configurations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSessionConfiguration+Configurations.swift"; sourceTree = ""; }; + FF1A69EB219AE1D500D735F6 /* SentTimezonedDatePredicateOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentTimezonedDatePredicateOperation.swift; sourceTree = ""; }; + FF2193EC217F0314003417FF /* OperationIsReadyTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationIsReadyTestCase.swift; sourceTree = ""; }; + FF2193EE217F0528003417FF /* OperationIsExecutingTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationIsExecutingTestCase.swift; sourceTree = ""; }; + FF2193F0217F07E7003417FF /* OperationTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationTestCase.swift; sourceTree = ""; }; + FF2193F2217F085A003417FF /* OperationIsFinishedTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationIsFinishedTestCase.swift; sourceTree = ""; }; + FF2193F4217F088D003417FF /* StandByOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandByOperation.swift; sourceTree = ""; }; + FF2193F6217F08AB003417FF /* PlainOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainOperation.swift; sourceTree = ""; }; + FF2193F8217F1694003417FF /* KVOExpectation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KVOExpectation.swift; sourceTree = ""; }; + FF2193FB217F1718003417FF /* OperationIsAsyncTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationIsAsyncTestCase.swift; sourceTree = ""; }; + FF2193FD217F18C1003417FF /* OperationIsCancelled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationIsCancelled.swift; sourceTree = ""; }; + FF219436217F42CC003417FF /* InputOperationTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputOperationTestSuite.swift; sourceTree = ""; }; + FF219438217F46EC003417FF /* ClosureInputOperationTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosureInputOperationTestSuite.swift; sourceTree = ""; }; + FF21943A217F491C003417FF /* AsyncClosureInputOperationTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncClosureInputOperationTestSuite.swift; sourceTree = ""; }; + FF21943C217F4991003417FF /* OutputOperationTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputOperationTestSuite.swift; sourceTree = ""; }; + FF21943E217F4D5D003417FF /* ChainOperationTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChainOperationTestSuite.swift; sourceTree = ""; }; + FF219440217F4DAB003417FF /* BooleanOutputOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BooleanOutputOperation.swift; sourceTree = ""; }; + FF219442217F4DC0003417FF /* ErrorOutputOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorOutputOperation.swift; sourceTree = ""; }; + FF219445217F4F80003417FF /* ReverseBooleanOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReverseBooleanOperation.swift; sourceTree = ""; }; + FF219449217F56B8003417FF /* WaitOperationTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitOperationTestCase.swift; sourceTree = ""; }; + FF21944B217F59CD003417FF /* NextScheduleOperationTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextScheduleOperationTestCase.swift; sourceTree = ""; }; + FF21944D217F60A9003417FF /* AggregateOperationTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregateOperationTestCase.swift; sourceTree = ""; }; + FF219450217F626F003417FF /* ParseJSONOperationTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseJSONOperationTestSuite.swift; sourceTree = ""; }; + FF219452217F63D0003417FF /* ParseRADPayloadOperationTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseRADPayloadOperationTestCase.swift; sourceTree = ""; }; + FF2240CD218B269300F749EE /* ItemSessionRangesTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSessionRangesTestCase.swift; sourceTree = ""; }; + FF2240CF218B278100F749EE /* RADExtractionTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RADExtractionTestCase.swift; sourceTree = ""; }; + FF2397A220EA6AE5002C9A82 /* RadMetadata+CoreDataProperties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "RadMetadata+CoreDataProperties.swift"; sourceTree = ""; }; + FF2397A420EA6AE5002C9A82 /* Event+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Event+CoreDataClass.swift"; sourceTree = ""; }; + FF2397A520EA6AE5002C9A82 /* Event+CoreDataProperties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Event+CoreDataProperties.swift"; sourceTree = ""; }; + FF2397A820EA6AE5002C9A82 /* GenericObjectContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GenericObjectContainer.swift; sourceTree = ""; }; + FF2397A920EA6AE5002C9A82 /* RadMetadata+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "RadMetadata+CoreDataClass.swift"; sourceTree = ""; }; + FF243092214F8C3A00AA9C4F /* EmptyObjectBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyObjectBuilder.swift; sourceTree = ""; }; + FF243094214F980600AA9C4F /* ProcessItemCleanupOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessItemCleanupOperation.swift; sourceTree = ""; }; + FF243096214F982E00AA9C4F /* ExpiredSessionIDBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiredSessionIDBuilder.swift; sourceTree = ""; }; + FF243098214F984F00AA9C4F /* OldItemSessionIDOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OldItemSessionIDOperation.swift; sourceTree = ""; }; + FF24309D214FB94500AA9C4F /* CreateValidSessionPredicateOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateValidSessionPredicateOperation.swift; sourceTree = ""; }; + FF25BB47212C07E6000B2ECF /* FilterResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterResult.swift; sourceTree = ""; }; + FF2CD67B20D11D5700057CBB /* EventRegistration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventRegistration.swift; sourceTree = ""; }; + FF2E28DA214BD05700360149 /* NSString+MD5.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSString+MD5.h"; sourceTree = ""; }; + FF2E28DB214BD05700360149 /* NSString+MD5.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSString+MD5.m"; sourceTree = ""; }; + FF2EBFCF2183030100BDDE4C /* MetadataRelation+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataRelation+CoreDataClass.swift"; sourceTree = ""; }; + FF2EBFD02183030100BDDE4C /* MetadataRelation+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataRelation+CoreDataProperties.swift"; sourceTree = ""; }; + FF2EBFD12183030100BDDE4C /* TimezonedDate+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimezonedDate+CoreDataClass.swift"; sourceTree = ""; }; + FF2EBFD22183030100BDDE4C /* TimezonedDate+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimezonedDate+CoreDataProperties.swift"; sourceTree = ""; }; + FF2EBFD32183030100BDDE4C /* Rad+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Rad+CoreDataClass.swift"; sourceTree = ""; }; + FF2EBFD42183030100BDDE4C /* Rad+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Rad+CoreDataProperties.swift"; sourceTree = ""; }; + FF2EBFD52183030100BDDE4C /* ItemSession+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemSession+CoreDataClass.swift"; sourceTree = ""; }; + FF2EBFD62183030100BDDE4C /* ItemSession+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemSession+CoreDataProperties.swift"; sourceTree = ""; }; + FF2EBFD72183030100BDDE4C /* Range+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Range+CoreDataClass.swift"; sourceTree = ""; }; + FF2EBFD82183030100BDDE4C /* Range+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Range+CoreDataProperties.swift"; sourceTree = ""; }; + FF2EBFD92183030100BDDE4C /* ItemSessionID+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemSessionID+CoreDataClass.swift"; sourceTree = ""; }; + FF2EBFDA2183030100BDDE4C /* ItemSessionID+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemSessionID+CoreDataProperties.swift"; sourceTree = ""; }; + FF2EBFE92183457B00BDDE4C /* WeakReferenceTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakReferenceTestCase.swift; sourceTree = ""; }; + FF2EBFEB2183464F00BDDE4C /* WeakReferenceContainerTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakReferenceContainerTestCase.swift; sourceTree = ""; }; + FF380EF3217DBBAD0029A4D2 /* DateComponentsTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateComponentsTestCase.swift; sourceTree = ""; }; + FF380EF6217DC7E70029A4D2 /* XCTest+OptionalEqual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTest+OptionalEqual.swift"; sourceTree = ""; }; + FF380EF8217DC80C0029A4D2 /* XCTest+MessageOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTest+MessageOption.swift"; sourceTree = ""; }; + FF380EFA217DC8550029A4D2 /* Inversable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Inversable.swift; sourceTree = ""; }; + FF380EFC217DC8650029A4D2 /* Int+Inversible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Inversible.swift"; sourceTree = ""; }; + FF380F00217DD7B20029A4D2 /* DictionaryRawRepresentableTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryRawRepresentableTestCase.swift; sourceTree = ""; }; + FF380F02217DD93E0029A4D2 /* DispatchQueueTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchQueueTestCase.swift; sourceTree = ""; }; + FF380F04217DDCF50029A4D2 /* DoubleExpectationTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleExpectationTestSuite.swift; sourceTree = ""; }; + FF380F06217DDFAE0029A4D2 /* TimeIntervalTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeIntervalTestSuite.swift; sourceTree = ""; }; + FF380F08217DE2510029A4D2 /* SwiftMathFunctionTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftMathFunctionTestSuite.swift; sourceTree = ""; }; + FF380F0A217DE9B00029A4D2 /* UrlRequestTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlRequestTestCase.swift; sourceTree = ""; }; + FF380F0C217DEB870029A4D2 /* RangeReplaceableCollectionTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeReplaceableCollectionTestSuite.swift; sourceTree = ""; }; + FF380F0E217DEE0B0029A4D2 /* FoundationOperationDependencyTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationOperationDependencyTestCase.swift; sourceTree = ""; }; + FF380F10217DF3BB0029A4D2 /* NSStringMD5TestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSStringMD5TestSuite.swift; sourceTree = ""; }; + FF380F12217DF7270029A4D2 /* MD5_JSON.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MD5_JSON.json; sourceTree = ""; }; + FF48621F20CAA7B700B996C6 /* CMTimeFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMTimeFormatterTests.swift; sourceTree = ""; }; + FF48622120CAAACA00B996C6 /* TimeComponentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeComponentsTests.swift; sourceTree = ""; }; + FF48622420CAB10700B996C6 /* Double+Equality.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Equality.swift"; sourceTree = ""; }; + FF488F0921343CE100851FF1 /* DispatchQueue+Execution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Execution.swift"; sourceTree = ""; }; + FF4A048821906369003A466B /* SimpleTestCaseFullScheduling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleTestCaseFullScheduling.swift; sourceTree = ""; }; + FF4A048C21909071003A466B /* ItemSessionIDUnlockedTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSessionIDUnlockedTestSuite.swift; sourceTree = ""; }; + FF4A048E2190908E003A466B /* RadIDUnlockedTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadIDUnlockedTestSuite.swift; sourceTree = ""; }; + FF4A3FB5212AB56900970A97 /* ProcessItemSessionsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessItemSessionsOperation.swift; sourceTree = ""; }; + FF4A3FB7212AB58400970A97 /* RADPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RADPayload.swift; sourceTree = ""; }; + FF4A3FB9212AB59600970A97 /* ParseRADObjectsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseRADObjectsOperation.swift; sourceTree = ""; }; + FF4A3FBB212ABA3600970A97 /* Range+CMTimeExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Range+CMTimeExtension.swift"; sourceTree = ""; }; + FF4A3FBD212AD5A900970A97 /* DeleteOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteOperation.swift; sourceTree = ""; }; + FF4B389B213D58760026148C /* ResponseCheckOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseCheckOperation.swift; sourceTree = ""; }; + FF4B389D213D58850026148C /* NetworkOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkOperation.swift; sourceTree = ""; }; + FF4B389F213D58990026148C /* ConvertBatchOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConvertBatchOperation.swift; sourceTree = ""; }; + FF4B38A1213D58AC0026148C /* ProcessBatchesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessBatchesOperation.swift; sourceTree = ""; }; + FF4B38A3213D58BA0026148C /* AggregateOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregateOperation.swift; sourceTree = ""; }; + FF4B38A5213D58CF0026148C /* BatchEventsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchEventsOperation.swift; sourceTree = ""; }; + FF4B38A7213D5A880026148C /* NetworkResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkResult.swift; sourceTree = ""; }; + FF4B38A9213D5A910026148C /* ConversionResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversionResult.swift; sourceTree = ""; }; + FF5C613121523FBC004C94C2 /* LockRadOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockRadOperation.swift; sourceTree = ""; }; + FF5C613321525C61004C94C2 /* UnlockObjectsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockObjectsOperation.swift; sourceTree = ""; }; + FF5C613521525FBD004C94C2 /* UnlockedRadOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockedRadOperation.swift; sourceTree = ""; }; + FF5D6F79218C561000A3B8FF /* small_audio_file.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = small_audio_file.m4a; sourceTree = ""; }; + FF5D6F7B218C561700A3B8FF /* smallFile_10Events.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = smallFile_10Events.json; sourceTree = ""; }; + FF5D6F7D218C57EB00A3B8FF /* RangeCreationExpectationBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeCreationExpectationBuilder.swift; sourceTree = ""; }; + FF5D6F7F218C57FA00A3B8FF /* TimeRangeControllerEndOfFileTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeRangeControllerEndOfFileTestCase.swift; sourceTree = ""; }; + FF5D6F81218C6A4800A3B8FF /* 1_000_Events.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = 1_000_Events.json; sourceTree = ""; }; + FF5D6F83218C6A5100A3B8FF /* 1_000Events2TrackingUrls.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = 1_000Events2TrackingUrls.mp3; sourceTree = ""; }; + FF5D6F87218C80E000A3B8FF /* ItemSessionInactiveTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSessionInactiveTestSuite.swift; sourceTree = ""; }; + FF60BD162153D1370001595C /* AsyncClosureInputOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncClosureInputOperation.swift; sourceTree = ""; }; + FF6CEF0C20D7A887000661B1 /* ParsingTests_eventTimeWrongFormat.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = ParsingTests_eventTimeWrongFormat.json; sourceTree = ""; }; + FF6CEF0D20D7A887000661B1 /* ParsingTests_missingTimeProperty.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = ParsingTests_missingTimeProperty.json; sourceTree = ""; }; + FF6CEF0E20D7A887000661B1 /* ParsingTests_extraProperties.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = ParsingTests_extraProperties.json; sourceTree = ""; }; + FF6CEF0F20D7A887000661B1 /* ParsingTests_noURL.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = ParsingTests_noURL.json; sourceTree = ""; }; + FF6CEF1020D7A887000661B1 /* ParsingTests_mispelledProperty.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = ParsingTests_mispelledProperty.json; sourceTree = ""; }; + FF6CEF1220D7A887000661B1 /* no_URL_available.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = no_URL_available.m4a; sourceTree = ""; }; + FF6CEF1320D7A887000661B1 /* RAD_events_properties_not_available.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = RAD_events_properties_not_available.m4a; sourceTree = ""; }; + FF6CEF1420D7A887000661B1 /* RAD_extra_properties.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = RAD_extra_properties.m4a; sourceTree = ""; }; + FF6CEF1520D7A887000661B1 /* RAD_time_not_available.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = RAD_time_not_available.m4a; sourceTree = ""; }; + FF6CEF1620D7A887000661B1 /* RAD_time_wrong_format.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = RAD_time_wrong_format.m4a; sourceTree = ""; }; + FF6CEF1720D7A887000661B1 /* no_RAD_medata.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = no_RAD_medata.m4a; sourceTree = ""; }; + FF6CEF1820D7A887000661B1 /* RAD_metadata_properties_incorrectly_spelled.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = RAD_metadata_properties_incorrectly_spelled.m4a; sourceTree = ""; }; + FF6CEF2520D7C2C9000661B1 /* RAD_events.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = RAD_events.m4a; sourceTree = ""; }; + FF6DD31820EB55D900B898B9 /* Server+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Server+CoreDataClass.swift"; sourceTree = ""; }; + FF6DD31920EB55D900B898B9 /* Server+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Server+CoreDataProperties.swift"; sourceTree = ""; }; + FF6DD31D20EB60DF00B898B9 /* NSManagedObjectContext+AsyncFetchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+AsyncFetchResult.swift"; sourceTree = ""; }; + FF71BD7620D2406E00125217 /* Double+String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+String.swift"; sourceTree = ""; }; + FF71BD7D20D2602200125217 /* GeneratedEventsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratedEventsTests.swift; sourceTree = ""; }; + FF71BD7F20D2607D00125217 /* Bundle+Framework.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Framework.swift"; sourceTree = ""; }; + FF71BD8120D260C600125217 /* AnalyticsTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsTestCase.swift; sourceTree = ""; }; + FF7DC1FA20CEB56B0019DD84 /* ParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParsingTests.swift; sourceTree = ""; }; + FF81E6AC2151394900504060 /* LockSessionOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockSessionOperation.swift; sourceTree = ""; }; + FF81E6AE21513B5D00504060 /* ContextFetchOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextFetchOperation.swift; sourceTree = ""; }; + FF81E6B021513D6F00504060 /* UnlockedSessionBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockedSessionBuilder.swift; sourceTree = ""; }; + FF84B90E2187396900D48755 /* MockPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPlayer.swift; sourceTree = ""; }; + FF84B9112187595800D48755 /* SaveOperationTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveOperationTestCase.swift; sourceTree = ""; }; + FF9F14262121690200D1C39E /* UnitDuration+Subunits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UnitDuration+Subunits.swift"; sourceTree = ""; }; + FF9F14292121A90500D1C39E /* TimeRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeRange.swift; sourceTree = ""; }; + FF9F142B2121A95500D1C39E /* TimeRangeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeRangeController.swift; sourceTree = ""; }; + FF9F14332121B66400D1C39E /* RangeBound+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RangeBound+CoreDataClass.swift"; sourceTree = ""; }; + FF9F14342121B66400D1C39E /* RangeBound+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RangeBound+CoreDataProperties.swift"; sourceTree = ""; }; + FFA0C1A72191E66E00D684AA /* 180Events2TrackingUrls.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = 180Events2TrackingUrls.mp3; sourceTree = ""; }; + FFA0C1A82191E66E00D684AA /* 240Events2TrackingUrls.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = 240Events2TrackingUrls.mp3; sourceTree = ""; }; + FFA0C1AC2191EC0D00D684AA /* AnalyticsDebuggerExtractPayloadTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsDebuggerExtractPayloadTestCase.swift; sourceTree = ""; }; + FFA0C1AE2191FEE000D684AA /* AnalyticsTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsTestSuite.swift; sourceTree = ""; }; + FFA0C1B0219207D100D684AA /* MD5Checkable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5Checkable.swift; sourceTree = ""; }; + FFA9D5132126BD84004EBDD0 /* ContextTransferOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextTransferOperation.swift; sourceTree = ""; }; + FFA9D51A2126FDF8004EBDD0 /* FetchError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchError.swift; sourceTree = ""; }; + FFA9D51C2126FE2C004EBDD0 /* FetchOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchOperation.swift; sourceTree = ""; }; + FFA9D5222126FF90004EBDD0 /* CreateEmptyObjectPredicateOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateEmptyObjectPredicateOperation.swift; sourceTree = ""; }; + FFA9D5242126FFAF004EBDD0 /* CreateOldEventsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateOldEventsOperation.swift; sourceTree = ""; }; + FFA9D5272126FFEE004EBDD0 /* FindNextSchedule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindNextSchedule.swift; sourceTree = ""; }; + FFA9D5292127000A004EBDD0 /* WaitOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitOperation.swift; sourceTree = ""; }; + FFA9D52B21270020004EBDD0 /* Scheduling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Scheduling.swift; sourceTree = ""; }; + FFA9D52F2127035B004EBDD0 /* FilterEventsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterEventsOperation.swift; sourceTree = ""; }; + FFBDED9D20F5D3F500F80E61 /* NetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; + FFC031C5219C1C5F006A29B6 /* OHHTTPStubs.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OHHTTPStubs.framework; path = Carthage/Build/iOS/OHHTTPStubs.framework; sourceTree = SOURCE_ROOT; }; + FFC031C7219C1C64006A29B6 /* Reachability.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Reachability.framework; path = Carthage/Build/iOS/Reachability.framework; sourceTree = SOURCE_ROOT; }; + FFC50CA82181D9FB00748BA9 /* Event+ObjectConvertibleExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Event+ObjectConvertibleExtension.swift"; sourceTree = ""; }; + FFC50CAA2181DA2100748BA9 /* ItemSession+ObjectConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemSession+ObjectConvertible.swift"; sourceTree = ""; }; + FFC50CAC2181DA3800748BA9 /* ItemSessionID+ObjectConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemSessionID+ObjectConvertible.swift"; sourceTree = ""; }; + FFC50CAE2181DA4A00748BA9 /* Rad+ObjectConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Rad+ObjectConvertible.swift"; sourceTree = ""; }; + FFC50CB02181DA6000748BA9 /* RadMetadata+ObjectConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RadMetadata+ObjectConvertible.swift"; sourceTree = ""; }; + FFC50CB22181DA7600748BA9 /* MetadataRelation+ObjectConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataRelation+ObjectConvertible.swift"; sourceTree = ""; }; + FFC50CB42181DA8600748BA9 /* Range+ObjectConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Range+ObjectConvertible.swift"; sourceTree = ""; }; + FFC50CB62181DAAF00748BA9 /* RangeBound+ObjectConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RangeBound+ObjectConvertible.swift"; sourceTree = ""; }; + FFC50CB82181DAC000748BA9 /* Server+ObjectConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Server+ObjectConvertible.swift"; sourceTree = ""; }; + FFC50CBA2181DACF00748BA9 /* TimezonedDate+ObjectConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimezonedDate+ObjectConvertible.swift"; sourceTree = ""; }; + FFD0D71720F8CEF6009FD005 /* Date+Now.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Now.swift"; sourceTree = ""; }; + FFD0D71920F8D6B7009FD005 /* DateComponents+NegativeOperator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateComponents+NegativeOperator.swift"; sourceTree = ""; }; + FFE8B56920E6599C0038A53B /* NetworkScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkScheduler.swift; sourceTree = ""; }; + FFE8B56D20E659DE0038A53B /* RequestBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestBuilder.swift; sourceTree = ""; }; + FFE8B57120E6604B0038A53B /* URLRequest+HttpMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+HttpMethod.swift"; sourceTree = ""; }; + FFF184C020ECAEE4009682D8 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; + FFF184C220ECB0B3009682D8 /* TimeInterval+Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Components.swift"; sourceTree = ""; }; + FFF184C420ECB48C009682D8 /* UserDefaultsKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsKeys.swift; sourceTree = ""; }; + FFF184C620ECD44D009682D8 /* Batch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Batch.swift; sourceTree = ""; }; + FFF184C820ECD46B009682D8 /* MetadataGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataGroup.swift; sourceTree = ""; }; + FFF184CA20ECD9F1009682D8 /* SwiftMathFunctions+Measurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftMathFunctions+Measurement.swift"; sourceTree = ""; }; + FFF285EB20C922CC00BCFF0A /* PlayerObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerObserver.swift; sourceTree = ""; }; + FFF285EF20C9234400BCFF0A /* CMTimeFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMTimeFormatter.swift; sourceTree = ""; }; + FFF285F220C94DF400BCFF0A /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; + FFF285FF20C94F4900BCFF0A /* Dictionary+RawRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+RawRepresentable.swift"; sourceTree = ""; }; + FFF2860120C95AD400BCFF0A /* TimeComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeComponents.swift; sourceTree = ""; }; + FFF2860620C95B3300BCFF0A /* CMTimeScale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMTimeScale.swift; sourceTree = ""; }; + FFF37B5120C8199900089FF0 /* Analytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = ""; }; + FFFA0CA320AF0BEA007D1F9C /* RAD.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = RAD.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FFFA0CA620AF0BEA007D1F9C /* RAD.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RAD.h; sourceTree = ""; }; + FFFA0CA720AF0BEA007D1F9C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FFFA0CAC20AF0BEA007D1F9C /* RADTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RADTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + FFFA0CB120AF0BEA007D1F9C /* RADTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RADTests.swift; sourceTree = ""; }; + FFFA0CB320AF0BEA007D1F9C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FFFF629820DA71E700CB1A60 /* RADModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = RADModel.xcdatamodel; sourceTree = ""; }; + FFFF62A620DA7A5D00CB1A60 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; + FFFF62A920DA86B700CB1A60 /* Bundle+Framework.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Framework.swift"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + FFFA0C9F20AF0BEA007D1F9C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FFC031C8219C1C64006A29B6 /* Reachability.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FFFA0CA920AF0BEA007D1F9C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FFFA0CAD20AF0BEA007D1F9C /* RAD.framework in Frameworks */, + FFC031C6219C1C5F006A29B6 /* OHHTTPStubs.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + FF02629D21258F1C007DF038 /* Operations */ = { + isa = PBXGroup; + children = ( + FF02629E21258F23007DF038 /* ChainedOperations */, + FF0262B1212590C0007DF038 /* CoreData */, + FFA9D51121269FDE004EBDD0 /* ItemSession */, + FFA9D5212126FF44004EBDD0 /* JsonProcessing */, + FFA9D5202126FF2E004EBDD0 /* PredicateCreation */, + FFA9D5262126FFD8004EBDD0 /* Scheduling */, + FFA9D52F2127035B004EBDD0 /* FilterEventsOperation.swift */, + FF4A3FB9212AB59600970A97 /* ParseRADObjectsOperation.swift */, + FF10A8652181D577008DEE36 /* ObjectConversionOperation.swift */, + ); + path = Operations; + sourceTree = ""; + }; + FF02629E21258F23007DF038 /* ChainedOperations */ = { + isa = PBXGroup; + children = ( + FF60BD162153D1370001595C /* AsyncClosureInputOperation.swift */, + FF0262A92125900E007DF038 /* ChainOperation.swift */, + FF0262A721258FDF007DF038 /* ClosureInputOperation.swift */, + FF0262A521258FC8007DF038 /* InputOperation.swift */, + FF02629F21258F67007DF038 /* Operation.swift */, + FF0262A321258F91007DF038 /* OutputOperation.swift */, + FF0262A121258F83007DF038 /* OutputOperationType.swift */, + ); + path = ChainedOperations; + sourceTree = ""; + }; + FF0262AD21259041007DF038 /* Errors */ = { + isa = PBXGroup; + children = ( + FFA9D51A2126FDF8004EBDD0 /* FetchError.swift */, + FF0262AB2125903B007DF038 /* InputError.swift */, + FF0262AE21259053007DF038 /* OutputError.swift */, + FF0262BF2125B2E8007DF038 /* ParseError.swift */, + ); + path = Errors; + sourceTree = ""; + }; + FF0262B1212590C0007DF038 /* CoreData */ = { + isa = PBXGroup; + children = ( + FF81E6AE21513B5D00504060 /* ContextFetchOperation.swift */, + FFA9D5132126BD84004EBDD0 /* ContextTransferOperation.swift */, + FF4A3FBD212AD5A900970A97 /* DeleteOperation.swift */, + FFA9D51C2126FE2C004EBDD0 /* FetchOperation.swift */, + FF0262B2212590D4007DF038 /* SaveContextOperation.swift */, + ); + path = CoreData; + sourceTree = ""; + }; + FF0ECF452179C8C1009C5528 /* Rad */ = { + isa = PBXGroup; + children = ( + FFC50CA82181D9FB00748BA9 /* Event+ObjectConvertibleExtension.swift */, + FFC50CAA2181DA2100748BA9 /* ItemSession+ObjectConvertible.swift */, + FFC50CAC2181DA3800748BA9 /* ItemSessionID+ObjectConvertible.swift */, + FFC50CB22181DA7600748BA9 /* MetadataRelation+ObjectConvertible.swift */, + FFC50CAE2181DA4A00748BA9 /* Rad+ObjectConvertible.swift */, + FFC50CB02181DA6000748BA9 /* RadMetadata+ObjectConvertible.swift */, + FFC50CB42181DA8600748BA9 /* Range+ObjectConvertible.swift */, + FFC50CB62181DAAF00748BA9 /* RangeBound+ObjectConvertible.swift */, + FFC50CB82181DAC000748BA9 /* Server+ObjectConvertible.swift */, + FFC50CBA2181DACF00748BA9 /* TimezonedDate+ObjectConvertible.swift */, + FF0ECF462179C8D8009C5528 /* UnitDuration+Roundable.swift */, + ); + path = Rad; + sourceTree = ""; + }; + FF1006642189E47600D54859 /* RAD */ = { + isa = PBXGroup; + children = ( + FF1006622189E47100D54859 /* Configuration+CustomValues.swift */, + ); + path = RAD; + sourceTree = ""; + }; + FF1006652189E7FA00D54859 /* ItemSessionTests */ = { + isa = PBXGroup; + children = ( + FF1006662189E88500D54859 /* CreateItemSessionOperationTestSuite.swift */, + FF2240CD218B269300F749EE /* ItemSessionRangesTestCase.swift */, + FF5D6F87218C80E000A3B8FF /* ItemSessionInactiveTestSuite.swift */, + FF4A048C21909071003A466B /* ItemSessionIDUnlockedTestSuite.swift */, + FF4A048E2190908E003A466B /* RadIDUnlockedTestSuite.swift */, + ); + path = ItemSessionTests; + sourceTree = ""; + }; + FF2193E8217EFFED003417FF /* ModelTests */ = { + isa = PBXGroup; + children = ( + FFA0C1AB2191EB8100D684AA /* DebuggerTests */, + FF2EBFE82183455C00BDDE4C /* EntitiesTests */, + FF2193E9217EFFF8003417FF /* OperationTests */, + FF4A048721906240003A466B /* SchedulingTests */, + ); + path = ModelTests; + sourceTree = ""; + }; + FF2193E9217EFFF8003417FF /* OperationTests */ = { + isa = PBXGroup; + children = ( + FF1006652189E7FA00D54859 /* ItemSessionTests */, + FF84B910218752C200D48755 /* CoreDataTests */, + FF21944F217F6259003417FF /* JsonParsing */, + FF2193EA217F0002003417FF /* ChainedOperationsTests */, + FF219448217F56A0003417FF /* SchedulingTests */, + FF2193F0217F07E7003417FF /* OperationTestCase.swift */, + FF2240CF218B278100F749EE /* RADExtractionTestCase.swift */, + ); + path = OperationTests; + sourceTree = ""; + }; + FF2193EA217F0002003417FF /* ChainedOperationsTests */ = { + isa = PBXGroup; + children = ( + FF2193EB217F000C003417FF /* OperationTestSuite */, + FF21943A217F491C003417FF /* AsyncClosureInputOperationTestSuite.swift */, + FF21943E217F4D5D003417FF /* ChainOperationTestSuite.swift */, + FF219438217F46EC003417FF /* ClosureInputOperationTestSuite.swift */, + FF219436217F42CC003417FF /* InputOperationTestSuite.swift */, + FF21943C217F4991003417FF /* OutputOperationTestSuite.swift */, + ); + path = ChainedOperationsTests; + sourceTree = ""; + }; + FF2193EB217F000C003417FF /* OperationTestSuite */ = { + isa = PBXGroup; + children = ( + FF2193FB217F1718003417FF /* OperationIsAsyncTestCase.swift */, + FF2193FD217F18C1003417FF /* OperationIsCancelled.swift */, + FF2193EE217F0528003417FF /* OperationIsExecutingTestCase.swift */, + FF2193F2217F085A003417FF /* OperationIsFinishedTestCase.swift */, + FF2193EC217F0314003417FF /* OperationIsReadyTestCase.swift */, + FF2193F6217F08AB003417FF /* PlainOperation.swift */, + FF2193F4217F088D003417FF /* StandByOperation.swift */, + ); + path = OperationTestSuite; + sourceTree = ""; + }; + FF219444217F4DD5003417FF /* Operations */ = { + isa = PBXGroup; + children = ( + FF219440217F4DAB003417FF /* BooleanOutputOperation.swift */, + FF154FA421805E2600E60011 /* ClosureOperation.swift */, + FF219442217F4DC0003417FF /* ErrorOutputOperation.swift */, + FF219445217F4F80003417FF /* ReverseBooleanOperation.swift */, + ); + path = Operations; + sourceTree = ""; + }; + FF219448217F56A0003417FF /* SchedulingTests */ = { + isa = PBXGroup; + children = ( + FF219449217F56B8003417FF /* WaitOperationTestCase.swift */, + FF21944B217F59CD003417FF /* NextScheduleOperationTestCase.swift */, + FF21944D217F60A9003417FF /* AggregateOperationTestCase.swift */, + ); + path = SchedulingTests; + sourceTree = ""; + }; + FF21944F217F6259003417FF /* JsonParsing */ = { + isa = PBXGroup; + children = ( + FF219450217F626F003417FF /* ParseJSONOperationTestSuite.swift */, + FF219452217F63D0003417FF /* ParseRADPayloadOperationTestCase.swift */, + ); + path = JsonParsing; + sourceTree = ""; + }; + FF2E28D9214BD02700360149 /* NSFoundation */ = { + isa = PBXGroup; + children = ( + FF2E28DA214BD05700360149 /* NSString+MD5.h */, + FF2E28DB214BD05700360149 /* NSString+MD5.m */, + ); + path = NSFoundation; + sourceTree = ""; + }; + FF2EBFE82183455C00BDDE4C /* EntitiesTests */ = { + isa = PBXGroup; + children = ( + FF2EBFE92183457B00BDDE4C /* WeakReferenceTestCase.swift */, + FF2EBFEB2183464F00BDDE4C /* WeakReferenceContainerTestCase.swift */, + FF12408121885FB900EA1060 /* TimeRangeControllerTestSuite.swift */, + FF1240882188A80E00EA1060 /* PlayerTestCase.swift */, + FF12408A2188A83100EA1060 /* TimeRangeCreationExpectation.swift */, + FF12408C2188A83E00EA1060 /* ItemDidPlayToEndExpectation.swift */, + FF5D6F7D218C57EB00A3B8FF /* RangeCreationExpectationBuilder.swift */, + FF5D6F7F218C57FA00A3B8FF /* TimeRangeControllerEndOfFileTestCase.swift */, + ); + path = EntitiesTests; + sourceTree = ""; + }; + FF380EF2217DBAF30029A4D2 /* ExtensionTests */ = { + isa = PBXGroup; + children = ( + FF380EF3217DBBAD0029A4D2 /* DateComponentsTestCase.swift */, + FF380F00217DD7B20029A4D2 /* DictionaryRawRepresentableTestCase.swift */, + FF380F02217DD93E0029A4D2 /* DispatchQueueTestCase.swift */, + FF380F04217DDCF50029A4D2 /* DoubleExpectationTestSuite.swift */, + FF380F0E217DEE0B0029A4D2 /* FoundationOperationDependencyTestCase.swift */, + FF380F0C217DEB870029A4D2 /* RangeReplaceableCollectionTestSuite.swift */, + FF380F08217DE2510029A4D2 /* SwiftMathFunctionTestSuite.swift */, + FF380F06217DDFAE0029A4D2 /* TimeIntervalTestSuite.swift */, + FF380F0A217DE9B00029A4D2 /* UrlRequestTestCase.swift */, + FF380F10217DF3BB0029A4D2 /* NSStringMD5TestSuite.swift */, + ); + path = ExtensionTests; + sourceTree = ""; + }; + FF380EF5217DC4290029A4D2 /* Tests */ = { + isa = PBXGroup; + children = ( + FF2193E8217EFFED003417FF /* ModelTests */, + FF380EF2217DBAF30029A4D2 /* ExtensionTests */, + FF71BD8120D260C600125217 /* AnalyticsTestCase.swift */, + FF48621F20CAA7B700B996C6 /* CMTimeFormatterTests.swift */, + FF71BD7D20D2602200125217 /* GeneratedEventsTests.swift */, + FF7DC1FA20CEB56B0019DD84 /* ParsingTests.swift */, + FF48622120CAAACA00B996C6 /* TimeComponentsTests.swift */, + FF14B2BF20E275BD008B5083 /* StorageTests.swift */, + FF14B2C120E27758008B5083 /* Player.swift */, + ); + path = Tests; + sourceTree = ""; + }; + FF380EFE217DD6BA0029A4D2 /* Foundation */ = { + isa = PBXGroup; + children = ( + FF71BD7F20D2607D00125217 /* Bundle+Framework.swift */, + FF48622420CAB10700B996C6 /* Double+Equality.swift */, + FF380EFC217DC8650029A4D2 /* Int+Inversible.swift */, + FF154FAA2180A5E300E60011 /* DispatchQueue+Queues.swift */, + FF154FAC2180A5F300E60011 /* OperationQueue+Queues.swift */, + ); + path = Foundation; + sourceTree = ""; + }; + FF380EFF217DD6C50029A4D2 /* XCTest */ = { + isa = PBXGroup; + children = ( + FF380EF6217DC7E70029A4D2 /* XCTest+OptionalEqual.swift */, + FF380EF8217DC80C0029A4D2 /* XCTest+MessageOption.swift */, + ); + path = XCTest; + sourceTree = ""; + }; + FF3ACDFF219311D5006B8A06 /* 3rdParty */ = { + isa = PBXGroup; + children = ( + FFC031C7219C1C64006A29B6 /* Reachability.framework */, + ); + name = 3rdParty; + sourceTree = ""; + }; + FF48621E20CAA79C00B996C6 /* Model */ = { + isa = PBXGroup; + children = ( + FF219444217F4DD5003417FF /* Operations */, + FF380EFA217DC8550029A4D2 /* Inversable.swift */, + FF2193F8217F1694003417FF /* KVOExpectation.swift */, + FFA0C1B0219207D100D684AA /* MD5Checkable.swift */, + FF84B90E2187396900D48755 /* MockPlayer.swift */, + ); + path = Model; + sourceTree = ""; + }; + FF48622320CAB0DE00B996C6 /* Extension */ = { + isa = PBXGroup; + children = ( + FF380EFE217DD6BA0029A4D2 /* Foundation */, + FF1006642189E47600D54859 /* RAD */, + FF380EFF217DD6C50029A4D2 /* XCTest */, + ); + path = Extension; + sourceTree = ""; + }; + FF4A048421906152003A466B /* Frameworks */ = { + isa = PBXGroup; + children = ( + FFC031C5219C1C5F006A29B6 /* OHHTTPStubs.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + FF4A048721906240003A466B /* SchedulingTests */ = { + isa = PBXGroup; + children = ( + FF4A048821906369003A466B /* SimpleTestCaseFullScheduling.swift */, + ); + path = SchedulingTests; + sourceTree = ""; + }; + FF6CEF0B20D7A887000661B1 /* RADPayload */ = { + isa = PBXGroup; + children = ( + FF6CEF0C20D7A887000661B1 /* ParsingTests_eventTimeWrongFormat.json */, + FF6CEF0D20D7A887000661B1 /* ParsingTests_missingTimeProperty.json */, + FF6CEF0E20D7A887000661B1 /* ParsingTests_extraProperties.json */, + FF6CEF0F20D7A887000661B1 /* ParsingTests_noURL.json */, + FF6CEF1020D7A887000661B1 /* ParsingTests_mispelledProperty.json */, + FF380F12217DF7270029A4D2 /* MD5_JSON.json */, + FF5D6F7B218C561700A3B8FF /* smallFile_10Events.json */, + FF5D6F81218C6A4800A3B8FF /* 1_000_Events.json */, + ); + path = RADPayload; + sourceTree = ""; + }; + FF6CEF1120D7A887000661B1 /* AudioFiles */ = { + isa = PBXGroup; + children = ( + FF1240852188937800EA1060 /* 50Events2TrackingUrls.mp3 */, + FF0E08332191960C003EA672 /* 60Events2TrackingUrls.mp3 */, + FF0E08322191960C003EA672 /* 80Events2TrackingUrls.mp3 */, + FF1240842188937700EA1060 /* 100Events2TrackingUrls.mp3 */, + FFA0C1A72191E66E00D684AA /* 180Events2TrackingUrls.mp3 */, + FFA0C1A82191E66E00D684AA /* 240Events2TrackingUrls.mp3 */, + FF5D6F83218C6A5100A3B8FF /* 1_000Events2TrackingUrls.mp3 */, + FF6CEF1220D7A887000661B1 /* no_URL_available.m4a */, + FF6CEF1720D7A887000661B1 /* no_RAD_medata.m4a */, + FF6CEF2520D7C2C9000661B1 /* RAD_events.m4a */, + FF6CEF1320D7A887000661B1 /* RAD_events_properties_not_available.m4a */, + FF6CEF1420D7A887000661B1 /* RAD_extra_properties.m4a */, + FF6CEF1820D7A887000661B1 /* RAD_metadata_properties_incorrectly_spelled.m4a */, + FF6CEF1520D7A887000661B1 /* RAD_time_not_available.m4a */, + FF6CEF1620D7A887000661B1 /* RAD_time_wrong_format.m4a */, + FF5D6F79218C561000A3B8FF /* small_audio_file.m4a */, + ); + path = AudioFiles; + sourceTree = ""; + }; + FF6DD31C20EB60C100B898B9 /* CoreData */ = { + isa = PBXGroup; + children = ( + FF6DD31D20EB60DF00B898B9 /* NSManagedObjectContext+AsyncFetchResult.swift */, + FF0ACB4320EE147C00B454E4 /* NSManagedObjectContext+Delete.swift */, + ); + path = CoreData; + sourceTree = ""; + }; + FF7DC1EB20CEB5280019DD84 /* Resources */ = { + isa = PBXGroup; + children = ( + FF6CEF1120D7A887000661B1 /* AudioFiles */, + FF6CEF0B20D7A887000661B1 /* RADPayload */, + ); + path = Resources; + sourceTree = ""; + }; + FF84B910218752C200D48755 /* CoreDataTests */ = { + isa = PBXGroup; + children = ( + FF84B9112187595800D48755 /* SaveOperationTestCase.swift */, + FF12407D21884BBC00EA1060 /* FetchOperationTestSuite.swift */, + FF12407F21884F3700EA1060 /* ContextTrasnferFetchTestSuite.swift */, + ); + path = CoreDataTests; + sourceTree = ""; + }; + FFA0C1AB2191EB8100D684AA /* DebuggerTests */ = { + isa = PBXGroup; + children = ( + FFA0C1AC2191EC0D00D684AA /* AnalyticsDebuggerExtractPayloadTestCase.swift */, + FFA0C1AE2191FEE000D684AA /* AnalyticsTestSuite.swift */, + ); + path = DebuggerTests; + sourceTree = ""; + }; + FFA9D51121269FDE004EBDD0 /* ItemSession */ = { + isa = PBXGroup; + children = ( + FF0262B5212595E7007DF038 /* ConvertTimeRangeOperation.swift */, + FF0262B92125ADF4007DF038 /* CreateItemSessionOperation.swift */, + FF0262B72125960B007DF038 /* ItemSessionOperation.swift */, + FF10CDAA214A4CBB0056AA95 /* ItemSessionDeactivateOperation.swift */, + FF81E6AC2151394900504060 /* LockSessionOperation.swift */, + FF5C613121523FBC004C94C2 /* LockRadOperation.swift */, + FF243094214F980600AA9C4F /* ProcessItemCleanupOperation.swift */, + FF4A3FB5212AB56900970A97 /* ProcessItemSessionsOperation.swift */, + FF5C613321525C61004C94C2 /* UnlockObjectsOperation.swift */, + ); + path = ItemSession; + sourceTree = ""; + }; + FFA9D5202126FF2E004EBDD0 /* PredicateCreation */ = { + isa = PBXGroup; + children = ( + FFA9D5222126FF90004EBDD0 /* CreateEmptyObjectPredicateOperation.swift */, + FFA9D5242126FFAF004EBDD0 /* CreateOldEventsOperation.swift */, + FF24309D214FB94500AA9C4F /* CreateValidSessionPredicateOperation.swift */, + FF243092214F8C3A00AA9C4F /* EmptyObjectBuilder.swift */, + FF243096214F982E00AA9C4F /* ExpiredSessionIDBuilder.swift */, + FF243098214F984F00AA9C4F /* OldItemSessionIDOperation.swift */, + FF1A69EB219AE1D500D735F6 /* SentTimezonedDatePredicateOperation.swift */, + FF81E6B021513D6F00504060 /* UnlockedSessionBuilder.swift */, + FF5C613521525FBD004C94C2 /* UnlockedRadOperation.swift */, + ); + path = PredicateCreation; + sourceTree = ""; + }; + FFA9D5212126FF44004EBDD0 /* JsonProcessing */ = { + isa = PBXGroup; + children = ( + FF0262C12125B42B007DF038 /* ParseJSONOperation.swift */, + FF0262BB2125AE12007DF038 /* ParseRADPayloadOperation.swift */, + FF0263002125DCF6007DF038 /* PrettyJSONOperation.swift */, + ); + path = JsonProcessing; + sourceTree = ""; + }; + FFA9D5262126FFD8004EBDD0 /* Scheduling */ = { + isa = PBXGroup; + children = ( + FF4B38A3213D58BA0026148C /* AggregateOperation.swift */, + FF4B38A5213D58CF0026148C /* BatchEventsOperation.swift */, + FF4B389F213D58990026148C /* ConvertBatchOperation.swift */, + FFA9D5272126FFEE004EBDD0 /* FindNextSchedule.swift */, + FF4B389D213D58850026148C /* NetworkOperation.swift */, + FF4B38A1213D58AC0026148C /* ProcessBatchesOperation.swift */, + FF4B389B213D58760026148C /* ResponseCheckOperation.swift */, + FF062A69213573880063C5E8 /* ScheduleDataSend.swift */, + FF062A6B213575950063C5E8 /* SendDataOperation.swift */, + FFA9D5292127000A004EBDD0 /* WaitOperation.swift */, + FF0F5A79215A4DFB00FEF83F /* SendDataCleanupOperation.swift */, + ); + path = Scheduling; + sourceTree = ""; + }; + FFE8B56420E650670038A53B /* Network */ = { + isa = PBXGroup; + children = ( + FFE8B56920E6599C0038A53B /* NetworkScheduler.swift */, + FFBDED9D20F5D3F500F80E61 /* NetworkService.swift */, + FFE8B56D20E659DE0038A53B /* RequestBuilder.swift */, + FF0ACB4920EE4EC900B454E4 /* HttpStatusCode.swift */, + FF0ACB4C20EE69F400B454E4 /* HttpStatusCodeList.swift */, + FF0ACB4E20EE6A7F00B454E4 /* HttpStatusCodeMapping.swift */, + ); + path = Network; + sourceTree = ""; + }; + FFF285E720C9141E00BCFF0A /* Model */ = { + isa = PBXGroup; + children = ( + FF02629D21258F1C007DF038 /* Operations */, + FFF285EE20C9233100BCFF0A /* Data Conversion */, + FFF285ED20C9232B00BCFF0A /* Entities */, + FFE8B56420E650670038A53B /* Network */, + FFFF629720DA71E700CB1A60 /* RADDatabaseModel.xcdatamodeld */, + FFF2860620C95B3300BCFF0A /* CMTimeScale.swift */, + FF10A8632181D54E008DEE36 /* DatabaseFetcher.swift */, + FF154FB02180B60D00E60011 /* ListeningObserver.swift */, + FF154FAE2180B5FF00E60011 /* Object.swift */, + FF0ECF432179C8AC009C5528 /* Roundable.swift */, + ); + path = Model; + sourceTree = ""; + }; + FFF285ED20C9232B00BCFF0A /* Entities */ = { + isa = PBXGroup; + children = ( + FF0262AD21259041007DF038 /* Errors */, + FFFF62A420DA7A2900CB1A60 /* CoreData */, + FFF184C620ECD44D009682D8 /* Batch.swift */, + FFF184C020ECAEE4009682D8 /* Configuration.swift */, + FF4B38A9213D5A910026148C /* ConversionResult.swift */, + FF2CD67B20D11D5700057CBB /* EventRegistration.swift */, + FF25BB47212C07E6000B2ECF /* FilterResult.swift */, + FFF285F220C94DF400BCFF0A /* JSON.swift */, + FFF184C820ECD46B009682D8 /* MetadataGroup.swift */, + FF4B38A7213D5A880026148C /* NetworkResult.swift */, + FFF285EB20C922CC00BCFF0A /* PlayerObserver.swift */, + FF4A3FB7212AB58400970A97 /* RADPayload.swift */, + FFA9D52B21270020004EBDD0 /* Scheduling.swift */, + FFF2860120C95AD400BCFF0A /* TimeComponents.swift */, + FF062A63213544310063C5E8 /* Timer.swift */, + FF9F14292121A90500D1C39E /* TimeRange.swift */, + FF0262BD2125AE5D007DF038 /* TimeRangeBound.swift */, + FF00128A21380772008740D0 /* TimeRangeBoundBuilder.swift */, + FF9F142B2121A95500D1C39E /* TimeRangeController.swift */, + FFF184C420ECB48C009682D8 /* UserDefaultsKeys.swift */, + FF062A652135533F0063C5E8 /* WeakReference.swift */, + FF062A67213553FB0063C5E8 /* WeakReferenceContainer.swift */, + ); + path = Entities; + sourceTree = ""; + }; + FFF285EE20C9233100BCFF0A /* Data Conversion */ = { + isa = PBXGroup; + children = ( + FFF285EF20C9234400BCFF0A /* CMTimeFormatter.swift */, + FF10A8612181D4D7008DEE36 /* ObjectConvertible.swift */, + ); + path = "Data Conversion"; + sourceTree = ""; + }; + FFF285F420C94E1000BCFF0A /* Extensions */ = { + isa = PBXGroup; + children = ( + FF6DD31C20EB60C100B898B9 /* CoreData */, + FFF285F520C94E1700BCFF0A /* Foundation */, + FF2E28D9214BD02700360149 /* NSFoundation */, + FF0ECF452179C8C1009C5528 /* Rad */, + ); + path = Extensions; + sourceTree = ""; + }; + FFF285F520C94E1700BCFF0A /* Foundation */ = { + isa = PBXGroup; + children = ( + FFFF62A920DA86B700CB1A60 /* Bundle+Framework.swift */, + FFD0D71720F8CEF6009FD005 /* Date+Now.swift */, + FFD0D71920F8D6B7009FD005 /* DateComponents+NegativeOperator.swift */, + FFF285FF20C94F4900BCFF0A /* Dictionary+RawRepresentable.swift */, + FF16FA2C20F37F2500ED572E /* DispatchQueue+Background.swift */, + FF488F0921343CE100851FF1 /* DispatchQueue+Execution.swift */, + FF71BD7620D2406E00125217 /* Double+String.swift */, + FF0F5A7D215A5B8000FEF83F /* FoundationOperation+OptionalDependency.swift */, + FF16FA2E20F37F3700ED572E /* OperationQueue+Background.swift */, + FF0F5A7B215A558F00FEF83F /* RangeReplaceableCollection+Operators.swift */, + FFF184CA20ECD9F1009682D8 /* SwiftMathFunctions+Measurement.swift */, + FFF184C220ECB0B3009682D8 /* TimeInterval+Components.swift */, + FF9F14262121690200D1C39E /* UnitDuration+Subunits.swift */, + FFE8B57120E6604B0038A53B /* URLRequest+HttpMethod.swift */, + FF16FA3020F3897400ED572E /* URLSessionConfiguration+Configurations.swift */, + ); + path = Foundation; + sourceTree = ""; + }; + FFFA0C9920AF0BEA007D1F9C = { + isa = PBXGroup; + children = ( + FFFA0CA520AF0BEA007D1F9C /* RAD */, + FFFA0CB020AF0BEA007D1F9C /* RADTests */, + FFFA0CA420AF0BEA007D1F9C /* Products */, + ); + sourceTree = ""; + }; + FFFA0CA420AF0BEA007D1F9C /* Products */ = { + isa = PBXGroup; + children = ( + FFFA0CA320AF0BEA007D1F9C /* RAD.framework */, + FFFA0CAC20AF0BEA007D1F9C /* RADTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + FFFA0CA520AF0BEA007D1F9C /* RAD */ = { + isa = PBXGroup; + children = ( + FF3ACDFF219311D5006B8A06 /* 3rdParty */, + FFF285F420C94E1000BCFF0A /* Extensions */, + FFF285E720C9141E00BCFF0A /* Model */, + FFFF62A520DA7A4E00CB1A60 /* Services */, + FFF37B5120C8199900089FF0 /* Analytics.swift */, + FF154FB22180B61700E60011 /* AnalyticsDebuggable.swift */, + FF10A85E2181A60E008DEE36 /* AnalyticsDebugger.swift */, + FFFA0CA620AF0BEA007D1F9C /* RAD.h */, + FFFA0CA720AF0BEA007D1F9C /* Info.plist */, + ); + path = RAD; + sourceTree = ""; + }; + FFFA0CB020AF0BEA007D1F9C /* RADTests */ = { + isa = PBXGroup; + children = ( + FF48622320CAB0DE00B996C6 /* Extension */, + FF4A048421906152003A466B /* Frameworks */, + FF48621E20CAA79C00B996C6 /* Model */, + FF380EF5217DC4290029A4D2 /* Tests */, + FF7DC1EB20CEB5280019DD84 /* Resources */, + FFFA0CB120AF0BEA007D1F9C /* RADTests.swift */, + FFFA0CB320AF0BEA007D1F9C /* Info.plist */, + ); + path = RADTests; + sourceTree = ""; + }; + FFFF62A420DA7A2900CB1A60 /* CoreData */ = { + isa = PBXGroup; + children = ( + FF2397A420EA6AE5002C9A82 /* Event+CoreDataClass.swift */, + FF2397A520EA6AE5002C9A82 /* Event+CoreDataProperties.swift */, + FF0ACB3820EE099700B454E4 /* Event+Convenience.swift */, + FF2397A820EA6AE5002C9A82 /* GenericObjectContainer.swift */, + FF2EBFD52183030100BDDE4C /* ItemSession+CoreDataClass.swift */, + FF2EBFD62183030100BDDE4C /* ItemSession+CoreDataProperties.swift */, + FF2EBFD92183030100BDDE4C /* ItemSessionID+CoreDataClass.swift */, + FF2EBFDA2183030100BDDE4C /* ItemSessionID+CoreDataProperties.swift */, + FF2EBFCF2183030100BDDE4C /* MetadataRelation+CoreDataClass.swift */, + FF2EBFD02183030100BDDE4C /* MetadataRelation+CoreDataProperties.swift */, + FF0F5A77215A436900FEF83F /* MetadataRelation+Convenience.swift */, + FF2EBFD32183030100BDDE4C /* Rad+CoreDataClass.swift */, + FF2EBFD42183030100BDDE4C /* Rad+CoreDataProperties.swift */, + FF0ACB3A20EE09A700B454E4 /* RadMetadata+Convenience.swift */, + FF2397A920EA6AE5002C9A82 /* RadMetadata+CoreDataClass.swift */, + FF2397A220EA6AE5002C9A82 /* RadMetadata+CoreDataProperties.swift */, + FF2EBFD72183030100BDDE4C /* Range+CoreDataClass.swift */, + FF2EBFD82183030100BDDE4C /* Range+CoreDataProperties.swift */, + FF4A3FBB212ABA3600970A97 /* Range+CMTimeExtension.swift */, + FF9F14332121B66400D1C39E /* RangeBound+CoreDataClass.swift */, + FF9F14342121B66400D1C39E /* RangeBound+CoreDataProperties.swift */, + FF0ACB3C20EE09BF00B454E4 /* Server+Convenience.swift */, + FF6DD31820EB55D900B898B9 /* Server+CoreDataClass.swift */, + FF6DD31920EB55D900B898B9 /* Server+CoreDataProperties.swift */, + FF2EBFD12183030100BDDE4C /* TimezonedDate+CoreDataClass.swift */, + FF2EBFD22183030100BDDE4C /* TimezonedDate+CoreDataProperties.swift */, + FF0F5A74215A22CD00FEF83F /* TimezonedDate+Convenience.swift */, + ); + path = CoreData; + sourceTree = ""; + }; + FFFF62A520DA7A4E00CB1A60 /* Services */ = { + isa = PBXGroup; + children = ( + FFFF62A620DA7A5D00CB1A60 /* Storage.swift */, + ); + path = Services; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + FFFA0CA020AF0BEA007D1F9C /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + FFFA0CB420AF0BEA007D1F9C /* RAD.h in Headers */, + FF2E28DC214BD05700360149 /* NSString+MD5.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + FFFA0CA220AF0BEA007D1F9C /* RAD */ = { + isa = PBXNativeTarget; + buildConfigurationList = FFFA0CB720AF0BEA007D1F9C /* Build configuration list for PBXNativeTarget "RAD" */; + buildPhases = ( + FF6CE86A219435FB002B2B95 /* Carthage Update Run Script */, + FFFA0C9E20AF0BEA007D1F9C /* Sources */, + FFFA0C9F20AF0BEA007D1F9C /* Frameworks */, + FFFA0CA020AF0BEA007D1F9C /* Headers */, + FFFA0CA120AF0BEA007D1F9C /* Resources */, + FFF285EA20C919B900BCFF0A /* Swiftlint Run Script */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = RAD; + productName = RAD; + productReference = FFFA0CA320AF0BEA007D1F9C /* RAD.framework */; + productType = "com.apple.product-type.framework"; + }; + FFFA0CAB20AF0BEA007D1F9C /* RADTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = FFFA0CBA20AF0BEA007D1F9C /* Build configuration list for PBXNativeTarget "RADTests" */; + buildPhases = ( + FFFA0CA820AF0BEA007D1F9C /* Sources */, + FFFA0CA920AF0BEA007D1F9C /* Frameworks */, + FFFA0CAA20AF0BEA007D1F9C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + FFFA0CAF20AF0BEA007D1F9C /* PBXTargetDependency */, + ); + name = RADTests; + productName = RADTests; + productReference = FFFA0CAC20AF0BEA007D1F9C /* RADTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + FFFA0C9A20AF0BEA007D1F9C /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0930; + LastUpgradeCheck = 0930; + ORGANIZATIONNAME = NPR; + TargetAttributes = { + FFFA0CA220AF0BEA007D1F9C = { + CreatedOnToolsVersion = 9.3.1; + LastSwiftMigration = 1010; + }; + FFFA0CAB20AF0BEA007D1F9C = { + CreatedOnToolsVersion = 9.3.1; + LastSwiftMigration = 1010; + }; + }; + }; + buildConfigurationList = FFFA0C9D20AF0BEA007D1F9C /* Build configuration list for PBXProject "RAD" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + ); + mainGroup = FFFA0C9920AF0BEA007D1F9C; + productRefGroup = FFFA0CA420AF0BEA007D1F9C /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + FFFA0CA220AF0BEA007D1F9C /* RAD */, + FFFA0CAB20AF0BEA007D1F9C /* RADTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + FFFA0CA120AF0BEA007D1F9C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FFFA0CAA20AF0BEA007D1F9C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FF6CEF1F20D7A887000661B1 /* RAD_events_properties_not_available.m4a in Resources */, + FF5D6F7A218C561100A3B8FF /* small_audio_file.m4a in Resources */, + FF1240862188937800EA1060 /* 100Events2TrackingUrls.mp3 in Resources */, + FF6CEF2220D7A887000661B1 /* RAD_time_wrong_format.m4a in Resources */, + FF5D6F82218C6A4800A3B8FF /* 1_000_Events.json in Resources */, + FF6CEF1B20D7A887000661B1 /* ParsingTests_extraProperties.json in Resources */, + FF6CEF1E20D7A887000661B1 /* no_URL_available.m4a in Resources */, + FF6CEF1920D7A887000661B1 /* ParsingTests_eventTimeWrongFormat.json in Resources */, + FFA0C1A92191E66E00D684AA /* 180Events2TrackingUrls.mp3 in Resources */, + FF6CEF1C20D7A887000661B1 /* ParsingTests_noURL.json in Resources */, + FF0E08352191960D003EA672 /* 60Events2TrackingUrls.mp3 in Resources */, + FF6CEF2020D7A887000661B1 /* RAD_extra_properties.m4a in Resources */, + FF6CEF1D20D7A887000661B1 /* ParsingTests_mispelledProperty.json in Resources */, + FF6CEF2420D7A887000661B1 /* RAD_metadata_properties_incorrectly_spelled.m4a in Resources */, + FFA0C1AA2191E66E00D684AA /* 240Events2TrackingUrls.mp3 in Resources */, + FF6CEF1A20D7A887000661B1 /* ParsingTests_missingTimeProperty.json in Resources */, + FF380F13217DF7270029A4D2 /* MD5_JSON.json in Resources */, + FF5D6F84218C6A5100A3B8FF /* 1_000Events2TrackingUrls.mp3 in Resources */, + FF1240872188937800EA1060 /* 50Events2TrackingUrls.mp3 in Resources */, + FF0E08342191960D003EA672 /* 80Events2TrackingUrls.mp3 in Resources */, + FF6CEF2120D7A887000661B1 /* RAD_time_not_available.m4a in Resources */, + FF6CEF2620D7C2C9000661B1 /* RAD_events.m4a in Resources */, + FF6CEF2320D7A887000661B1 /* no_RAD_medata.m4a in Resources */, + FF5D6F7C218C561700A3B8FF /* smallFile_10Events.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + FF6CE86A219435FB002B2B95 /* Carthage Update Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Carthage Update Run Script"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\n/usr/local/bin/carthage update --platform iOS --cache-builds --no-use-binaries\n"; + }; + FFF285EA20C919B900BCFF0A /* Swiftlint Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Swiftlint Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + FFFA0C9E20AF0BEA007D1F9C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FF2397AB20EA6AE6002C9A82 /* RadMetadata+CoreDataProperties.swift in Sources */, + FF10CDAB214A4CBB0056AA95 /* ItemSessionDeactivateOperation.swift in Sources */, + FFE8B57220E6604B0038A53B /* URLRequest+HttpMethod.swift in Sources */, + FFC50CBB2181DACF00748BA9 /* TimezonedDate+ObjectConvertible.swift in Sources */, + FF2EBFDB2183030100BDDE4C /* MetadataRelation+CoreDataClass.swift in Sources */, + FFF184C920ECD46B009682D8 /* MetadataGroup.swift in Sources */, + FFD0D71A20F8D6B7009FD005 /* DateComponents+NegativeOperator.swift in Sources */, + FF488F0A21343CE100851FF1 /* DispatchQueue+Execution.swift in Sources */, + FFC50CAF2181DA4A00748BA9 /* Rad+ObjectConvertible.swift in Sources */, + FFC50CB12181DA6000748BA9 /* RadMetadata+ObjectConvertible.swift in Sources */, + FF81E6AF21513B5D00504060 /* ContextFetchOperation.swift in Sources */, + FF0ACB3920EE099700B454E4 /* Event+Convenience.swift in Sources */, + FF0ACB3B20EE09A700B454E4 /* RadMetadata+Convenience.swift in Sources */, + FF9F14362121B66500D1C39E /* RangeBound+CoreDataProperties.swift in Sources */, + FF0262AA2125900E007DF038 /* ChainOperation.swift in Sources */, + FF0262AF21259053007DF038 /* OutputError.swift in Sources */, + FF2EBFDF2183030100BDDE4C /* Rad+CoreDataClass.swift in Sources */, + FFF184CB20ECD9F1009682D8 /* SwiftMathFunctions+Measurement.swift in Sources */, + FF4B38A6213D58CF0026148C /* BatchEventsOperation.swift in Sources */, + FF4B38A4213D58BA0026148C /* AggregateOperation.swift in Sources */, + FF0ECF472179C8D8009C5528 /* UnitDuration+Roundable.swift in Sources */, + FF2EBFDD2183030100BDDE4C /* TimezonedDate+CoreDataClass.swift in Sources */, + FF0ACB4D20EE69F400B454E4 /* HttpStatusCodeList.swift in Sources */, + FF0262BE2125AE5D007DF038 /* TimeRangeBound.swift in Sources */, + FF9F14352121B66500D1C39E /* RangeBound+CoreDataClass.swift in Sources */, + FF5C613621525FBD004C94C2 /* UnlockedRadOperation.swift in Sources */, + FFF285EC20C922CC00BCFF0A /* PlayerObserver.swift in Sources */, + FF4A3FBE212AD5A900970A97 /* DeleteOperation.swift in Sources */, + FF0ACB4F20EE6A7F00B454E4 /* HttpStatusCodeMapping.swift in Sources */, + FFBDED9E20F5D3F500F80E61 /* NetworkService.swift in Sources */, + FFF184C520ECB48C009682D8 /* UserDefaultsKeys.swift in Sources */, + FFD0D71820F8CEF6009FD005 /* Date+Now.swift in Sources */, + FF2EBFDC2183030100BDDE4C /* MetadataRelation+CoreDataProperties.swift in Sources */, + FFFF62A720DA7A5D00CB1A60 /* Storage.swift in Sources */, + FF2EBFDE2183030100BDDE4C /* TimezonedDate+CoreDataProperties.swift in Sources */, + FF2397AE20EA6AE6002C9A82 /* Event+CoreDataProperties.swift in Sources */, + FF062A6A213573880063C5E8 /* ScheduleDataSend.swift in Sources */, + FF0262B82125960B007DF038 /* ItemSessionOperation.swift in Sources */, + FF0263012125DCF6007DF038 /* PrettyJSONOperation.swift in Sources */, + FF9F142C2121A95500D1C39E /* TimeRangeController.swift in Sources */, + FFF184C120ECAEE4009682D8 /* Configuration.swift in Sources */, + FF16FA2D20F37F2500ED572E /* DispatchQueue+Background.swift in Sources */, + FF71BD7720D2406E00125217 /* Double+String.swift in Sources */, + FF16FA3120F3897400ED572E /* URLSessionConfiguration+Configurations.swift in Sources */, + FFA9D52A2127000A004EBDD0 /* WaitOperation.swift in Sources */, + FFF285F320C94DF400BCFF0A /* JSON.swift in Sources */, + FF4B389C213D58760026148C /* ResponseCheckOperation.swift in Sources */, + FFA9D5302127035B004EBDD0 /* FilterEventsOperation.swift in Sources */, + FF062A68213553FB0063C5E8 /* WeakReferenceContainer.swift in Sources */, + FF062A64213544310063C5E8 /* Timer.swift in Sources */, + FF2EBFE02183030100BDDE4C /* Rad+CoreDataProperties.swift in Sources */, + FF6DD31E20EB60DF00B898B9 /* NSManagedObjectContext+AsyncFetchResult.swift in Sources */, + FF10A8662181D577008DEE36 /* ObjectConversionOperation.swift in Sources */, + FF2CD67C20D11D5700057CBB /* EventRegistration.swift in Sources */, + FF0F5A7E215A5B8000FEF83F /* FoundationOperation+OptionalDependency.swift in Sources */, + FF0ECF442179C8AC009C5528 /* Roundable.swift in Sources */, + FF2397B120EA6AE6002C9A82 /* GenericObjectContainer.swift in Sources */, + FF2EBFE42183030100BDDE4C /* Range+CoreDataProperties.swift in Sources */, + FF5C613421525C61004C94C2 /* UnlockObjectsOperation.swift in Sources */, + FFF184C720ECD44D009682D8 /* Batch.swift in Sources */, + FF2397AD20EA6AE6002C9A82 /* Event+CoreDataClass.swift in Sources */, + FFC50CB32181DA7600748BA9 /* MetadataRelation+ObjectConvertible.swift in Sources */, + FF9F142A2121A90500D1C39E /* TimeRange.swift in Sources */, + FFC50CAB2181DA2100748BA9 /* ItemSession+ObjectConvertible.swift in Sources */, + FF0F5A7A215A4DFB00FEF83F /* SendDataCleanupOperation.swift in Sources */, + FFC50CB72181DAAF00748BA9 /* RangeBound+ObjectConvertible.swift in Sources */, + FF2EBFE12183030100BDDE4C /* ItemSession+CoreDataClass.swift in Sources */, + FF243099214F984F00AA9C4F /* OldItemSessionIDOperation.swift in Sources */, + FFF2860220C95AD400BCFF0A /* TimeComponents.swift in Sources */, + FFA9D5142126BD84004EBDD0 /* ContextTransferOperation.swift in Sources */, + FF25BB48212C07E6000B2ECF /* FilterResult.swift in Sources */, + FF0262AC2125903B007DF038 /* InputError.swift in Sources */, + FF154FB12180B60D00E60011 /* ListeningObserver.swift in Sources */, + FF0262A621258FC8007DF038 /* InputOperation.swift in Sources */, + FF243097214F982E00AA9C4F /* ExpiredSessionIDBuilder.swift in Sources */, + FF4A3FBA212AB59600970A97 /* ParseRADObjectsOperation.swift in Sources */, + FF0F5A7C215A558F00FEF83F /* RangeReplaceableCollection+Operators.swift in Sources */, + FFF37B5220C8199900089FF0 /* Analytics.swift in Sources */, + FF16FA2F20F37F3700ED572E /* OperationQueue+Background.swift in Sources */, + FF0ACB4420EE147C00B454E4 /* NSManagedObjectContext+Delete.swift in Sources */, + FFC50CB52181DA8600748BA9 /* Range+ObjectConvertible.swift in Sources */, + FFE8B56E20E659DE0038A53B /* RequestBuilder.swift in Sources */, + FF4B38A2213D58AC0026148C /* ProcessBatchesOperation.swift in Sources */, + FF5C613221523FBC004C94C2 /* LockRadOperation.swift in Sources */, + FFC50CAD2181DA3800748BA9 /* ItemSessionID+ObjectConvertible.swift in Sources */, + FF4B389E213D58850026148C /* NetworkOperation.swift in Sources */, + FF10A85F2181A60E008DEE36 /* AnalyticsDebugger.swift in Sources */, + FF154FAF2180B5FF00E60011 /* Object.swift in Sources */, + FF81E6B121513D6F00504060 /* UnlockedSessionBuilder.swift in Sources */, + FFA9D5282126FFEE004EBDD0 /* FindNextSchedule.swift in Sources */, + FF0262A221258F83007DF038 /* OutputOperationType.swift in Sources */, + FF4B38A0213D58990026148C /* ConvertBatchOperation.swift in Sources */, + FF154FB32180B61700E60011 /* AnalyticsDebuggable.swift in Sources */, + FF10A8622181D4D7008DEE36 /* ObjectConvertible.swift in Sources */, + FF0262BC2125AE12007DF038 /* ParseRADPayloadOperation.swift in Sources */, + FFC50CB92181DAC000748BA9 /* Server+ObjectConvertible.swift in Sources */, + FFFF62AA20DA86B700CB1A60 /* Bundle+Framework.swift in Sources */, + FF2EBFE52183030100BDDE4C /* ItemSessionID+CoreDataClass.swift in Sources */, + FF0262B6212595E7007DF038 /* ConvertTimeRangeOperation.swift in Sources */, + FF243093214F8C3A00AA9C4F /* EmptyObjectBuilder.swift in Sources */, + FF6DD31B20EB55D900B898B9 /* Server+CoreDataProperties.swift in Sources */, + FF2EBFE32183030100BDDE4C /* Range+CoreDataClass.swift in Sources */, + FFA9D5252126FFAF004EBDD0 /* CreateOldEventsOperation.swift in Sources */, + FFF285F020C9234400BCFF0A /* CMTimeFormatter.swift in Sources */, + FF24309E214FB94500AA9C4F /* CreateValidSessionPredicateOperation.swift in Sources */, + FF0262A021258F67007DF038 /* Operation.swift in Sources */, + FF4B38A8213D5A880026148C /* NetworkResult.swift in Sources */, + FF9F14272121690200D1C39E /* UnitDuration+Subunits.swift in Sources */, + FF0ACB3D20EE09BF00B454E4 /* Server+Convenience.swift in Sources */, + FF2EBFE22183030100BDDE4C /* ItemSession+CoreDataProperties.swift in Sources */, + FF4A3FBC212ABA3600970A97 /* Range+CMTimeExtension.swift in Sources */, + FF00128B21380772008740D0 /* TimeRangeBoundBuilder.swift in Sources */, + FF0262A421258F91007DF038 /* OutputOperation.swift in Sources */, + FF4A3FB8212AB58400970A97 /* RADPayload.swift in Sources */, + FFA9D51D2126FE2C004EBDD0 /* FetchOperation.swift in Sources */, + FFA9D5232126FF90004EBDD0 /* CreateEmptyObjectPredicateOperation.swift in Sources */, + FFE8B56A20E6599C0038A53B /* NetworkScheduler.swift in Sources */, + FFA9D51B2126FDF8004EBDD0 /* FetchError.swift in Sources */, + FF0262C22125B42B007DF038 /* ParseJSONOperation.swift in Sources */, + FF0ACB4A20EE4EC900B454E4 /* HttpStatusCode.swift in Sources */, + FFFF629920DA71E700CB1A60 /* RADDatabaseModel.xcdatamodeld in Sources */, + FF4B38AA213D5A910026148C /* ConversionResult.swift in Sources */, + FF0F5A78215A436900FEF83F /* MetadataRelation+Convenience.swift in Sources */, + FF10A8642181D54E008DEE36 /* DatabaseFetcher.swift in Sources */, + FF60BD172153D1370001595C /* AsyncClosureInputOperation.swift in Sources */, + FF243095214F980600AA9C4F /* ProcessItemCleanupOperation.swift in Sources */, + FF6DD31A20EB55D900B898B9 /* Server+CoreDataClass.swift in Sources */, + FFF2860720C95B3300BCFF0A /* CMTimeScale.swift in Sources */, + FF2E28DD214BD05700360149 /* NSString+MD5.m in Sources */, + FF062A6C213575950063C5E8 /* SendDataOperation.swift in Sources */, + FF0262BA2125ADF4007DF038 /* CreateItemSessionOperation.swift in Sources */, + FF1A69EC219AE1D500D735F6 /* SentTimezonedDatePredicateOperation.swift in Sources */, + FF0262C02125B2E8007DF038 /* ParseError.swift in Sources */, + FF0262A821258FDF007DF038 /* ClosureInputOperation.swift in Sources */, + FF81E6AD2151394900504060 /* LockSessionOperation.swift in Sources */, + FF4A3FB6212AB56900970A97 /* ProcessItemSessionsOperation.swift in Sources */, + FFF184C320ECB0B3009682D8 /* TimeInterval+Components.swift in Sources */, + FF2EBFE62183030100BDDE4C /* ItemSessionID+CoreDataProperties.swift in Sources */, + FFF2860020C94F4900BCFF0A /* Dictionary+RawRepresentable.swift in Sources */, + FFC50CA92181D9FB00748BA9 /* Event+ObjectConvertibleExtension.swift in Sources */, + FFA9D52C21270020004EBDD0 /* Scheduling.swift in Sources */, + FF0262B3212590D4007DF038 /* SaveContextOperation.swift in Sources */, + FF0F5A75215A22CD00FEF83F /* TimezonedDate+Convenience.swift in Sources */, + FF062A662135533F0063C5E8 /* WeakReference.swift in Sources */, + FF2397B220EA6AE6002C9A82 /* RadMetadata+CoreDataClass.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FFFA0CA820AF0BEA007D1F9C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FF219453217F63D0003417FF /* ParseRADPayloadOperationTestCase.swift in Sources */, + FF2193FC217F1718003417FF /* OperationIsAsyncTestCase.swift in Sources */, + FF14B2C220E27758008B5083 /* Player.swift in Sources */, + FF12408D2188A83E00EA1060 /* ItemDidPlayToEndExpectation.swift in Sources */, + FF2EBFEA2183457B00BDDE4C /* WeakReferenceTestCase.swift in Sources */, + FF71BD7E20D2602200125217 /* GeneratedEventsTests.swift in Sources */, + FF219437217F42CC003417FF /* InputOperationTestSuite.swift in Sources */, + FF71BD8020D2607D00125217 /* Bundle+Framework.swift in Sources */, + FF380F07217DDFAE0029A4D2 /* TimeIntervalTestSuite.swift in Sources */, + FF21944E217F60A9003417FF /* AggregateOperationTestCase.swift in Sources */, + FFA0C1AD2191EC0D00D684AA /* AnalyticsDebuggerExtractPayloadTestCase.swift in Sources */, + FF84B9122187595800D48755 /* SaveOperationTestCase.swift in Sources */, + FF380EF7217DC7E70029A4D2 /* XCTest+OptionalEqual.swift in Sources */, + FF219451217F626F003417FF /* ParseJSONOperationTestSuite.swift in Sources */, + FF5D6F7E218C57EB00A3B8FF /* RangeCreationExpectationBuilder.swift in Sources */, + FF380EFB217DC8550029A4D2 /* Inversable.swift in Sources */, + FF154FAB2180A5E300E60011 /* DispatchQueue+Queues.swift in Sources */, + FF21944A217F56B8003417FF /* WaitOperationTestCase.swift in Sources */, + FF380EF4217DBBAD0029A4D2 /* DateComponentsTestCase.swift in Sources */, + FF21943D217F4991003417FF /* OutputOperationTestSuite.swift in Sources */, + FF2193ED217F0314003417FF /* OperationIsReadyTestCase.swift in Sources */, + FF1006632189E47100D54859 /* Configuration+CustomValues.swift in Sources */, + FF12408B2188A83100EA1060 /* TimeRangeCreationExpectation.swift in Sources */, + FF21943B217F491C003417FF /* AsyncClosureInputOperationTestSuite.swift in Sources */, + FF1006672189E88500D54859 /* CreateItemSessionOperationTestSuite.swift in Sources */, + FF84B90F2187396900D48755 /* MockPlayer.swift in Sources */, + FFFA0CB220AF0BEA007D1F9C /* RADTests.swift in Sources */, + FF219439217F46EC003417FF /* ClosureInputOperationTestSuite.swift in Sources */, + FF48622220CAAACA00B996C6 /* TimeComponentsTests.swift in Sources */, + FF380EF9217DC80C0029A4D2 /* XCTest+MessageOption.swift in Sources */, + FF219446217F4F80003417FF /* ReverseBooleanOperation.swift in Sources */, + FF48622520CAB10700B996C6 /* Double+Equality.swift in Sources */, + FF7DC1FB20CEB56B0019DD84 /* ParsingTests.swift in Sources */, + FF380F01217DD7B20029A4D2 /* DictionaryRawRepresentableTestCase.swift in Sources */, + FF2EBFEC2183464F00BDDE4C /* WeakReferenceContainerTestCase.swift in Sources */, + FF380F11217DF3BB0029A4D2 /* NSStringMD5TestSuite.swift in Sources */, + FF2193F5217F088D003417FF /* StandByOperation.swift in Sources */, + FF154FA521805E2600E60011 /* ClosureOperation.swift in Sources */, + FF380F0D217DEB870029A4D2 /* RangeReplaceableCollectionTestSuite.swift in Sources */, + FF2193EF217F0528003417FF /* OperationIsExecutingTestCase.swift in Sources */, + FF380F0F217DEE0B0029A4D2 /* FoundationOperationDependencyTestCase.swift in Sources */, + FF2240CE218B269300F749EE /* ItemSessionRangesTestCase.swift in Sources */, + FF12408221885FB900EA1060 /* TimeRangeControllerTestSuite.swift in Sources */, + FF71BD8220D260C600125217 /* AnalyticsTestCase.swift in Sources */, + FF4A048921906369003A466B /* SimpleTestCaseFullScheduling.swift in Sources */, + FF5D6F80218C57FA00A3B8FF /* TimeRangeControllerEndOfFileTestCase.swift in Sources */, + FF219443217F4DC0003417FF /* ErrorOutputOperation.swift in Sources */, + FF21943F217F4D5D003417FF /* ChainOperationTestSuite.swift in Sources */, + FF219441217F4DAB003417FF /* BooleanOutputOperation.swift in Sources */, + FF380EFD217DC8650029A4D2 /* Int+Inversible.swift in Sources */, + FF48622020CAA7B700B996C6 /* CMTimeFormatterTests.swift in Sources */, + FF2193F3217F085A003417FF /* OperationIsFinishedTestCase.swift in Sources */, + FF1240892188A80E00EA1060 /* PlayerTestCase.swift in Sources */, + FF12407E21884BBC00EA1060 /* FetchOperationTestSuite.swift in Sources */, + FF21944C217F59CD003417FF /* NextScheduleOperationTestCase.swift in Sources */, + FF380F09217DE2510029A4D2 /* SwiftMathFunctionTestSuite.swift in Sources */, + FF2193F9217F1694003417FF /* KVOExpectation.swift in Sources */, + FF4A048F2190908E003A466B /* RadIDUnlockedTestSuite.swift in Sources */, + FF12408021884F3700EA1060 /* ContextTrasnferFetchTestSuite.swift in Sources */, + FF380F05217DDCF50029A4D2 /* DoubleExpectationTestSuite.swift in Sources */, + FF380F0B217DE9B00029A4D2 /* UrlRequestTestCase.swift in Sources */, + FFA0C1B1219207D100D684AA /* MD5Checkable.swift in Sources */, + FFA0C1AF2191FEE000D684AA /* AnalyticsTestSuite.swift in Sources */, + FF2193FE217F18C1003417FF /* OperationIsCancelled.swift in Sources */, + FF5D6F88218C80E000A3B8FF /* ItemSessionInactiveTestSuite.swift in Sources */, + FF2193F7217F08AB003417FF /* PlainOperation.swift in Sources */, + FF4A048D21909071003A466B /* ItemSessionIDUnlockedTestSuite.swift in Sources */, + FF2240D0218B278100F749EE /* RADExtractionTestCase.swift in Sources */, + FF154FAD2180A5F300E60011 /* OperationQueue+Queues.swift in Sources */, + FF14B2C020E275BD008B5083 /* StorageTests.swift in Sources */, + FF380F03217DD93E0029A4D2 /* DispatchQueueTestCase.swift in Sources */, + FF2193F1217F07E7003417FF /* OperationTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + FFFA0CAF20AF0BEA007D1F9C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FFFA0CA220AF0BEA007D1F9C /* RAD */; + targetProxy = FFFA0CAE20AF0BEA007D1F9C /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + FF4A047F2190578D003A466B /* Testing */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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_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; + CODE_SIGN_IDENTITY = "iPhone Developer"; + 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 = 11.3; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Testing; + }; + FF4A04802190578D003A466B /* Testing */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + INFOPLIST_FILE = RAD/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + PRODUCT_BUNDLE_IDENTIFIER = NPR.RAD; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = TESTING; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Testing; + }; + FF4A04812190578D003A466B /* Testing */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + INFOPLIST_FILE = RADTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + PRODUCT_BUNDLE_IDENTIFIER = NPR.RADTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Testing; + }; + FFFA0CB520AF0BEA007D1F9C /* 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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_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; + CODE_SIGN_IDENTITY = "iPhone Developer"; + 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 = 11.3; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + FFFA0CB620AF0BEA007D1F9C /* 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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_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; + CODE_SIGN_IDENTITY = "iPhone Developer"; + 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 = 11.3; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + FFFA0CB820AF0BEA007D1F9C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + INFOPLIST_FILE = RAD/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + PRODUCT_BUNDLE_IDENTIFIER = NPR.RAD; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FFFA0CB920AF0BEA007D1F9C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + INFOPLIST_FILE = RAD/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + PRODUCT_BUNDLE_IDENTIFIER = NPR.RAD; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + FFFA0CBB20AF0BEA007D1F9C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + INFOPLIST_FILE = RADTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + PRODUCT_BUNDLE_IDENTIFIER = NPR.RADTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FFFA0CBC20AF0BEA007D1F9C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + INFOPLIST_FILE = RADTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + PRODUCT_BUNDLE_IDENTIFIER = NPR.RADTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + FFFA0C9D20AF0BEA007D1F9C /* Build configuration list for PBXProject "RAD" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FFFA0CB520AF0BEA007D1F9C /* Debug */, + FF4A047F2190578D003A466B /* Testing */, + FFFA0CB620AF0BEA007D1F9C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FFFA0CB720AF0BEA007D1F9C /* Build configuration list for PBXNativeTarget "RAD" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FFFA0CB820AF0BEA007D1F9C /* Debug */, + FF4A04802190578D003A466B /* Testing */, + FFFA0CB920AF0BEA007D1F9C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FFFA0CBA20AF0BEA007D1F9C /* Build configuration list for PBXNativeTarget "RADTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FFFA0CBB20AF0BEA007D1F9C /* Debug */, + FF4A04812190578D003A466B /* Testing */, + FFFA0CBC20AF0BEA007D1F9C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCVersionGroup section */ + FFFF629720DA71E700CB1A60 /* RADDatabaseModel.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + FFFF629820DA71E700CB1A60 /* RADModel.xcdatamodel */, + ); + currentVersion = FFFF629820DA71E700CB1A60 /* RADModel.xcdatamodel */; + path = RADDatabaseModel.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ + }; + rootObject = FFFA0C9A20AF0BEA007D1F9C /* Project object */; +} diff --git a/RAD.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/RAD.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..4c81b6d --- /dev/null +++ b/RAD.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/RAD.xcodeproj/project.xcworkspace/xcshareddata/IDETemplateMacros.plist b/RAD.xcodeproj/project.xcworkspace/xcshareddata/IDETemplateMacros.plist new file mode 100644 index 0000000..8b066fc --- /dev/null +++ b/RAD.xcodeproj/project.xcworkspace/xcshareddata/IDETemplateMacros.plist @@ -0,0 +1,23 @@ + + + + + FILEHEADER + +// ___FILENAME___ +// ___PACKAGENAME___ +// +// Copyright ___YEAR___ ___ORGANIZATIONNAME___ +// +// 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/RAD.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/RAD.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/RAD.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/RAD.xcodeproj/xcshareddata/xcschemes/RAD.xcscheme b/RAD.xcodeproj/xcshareddata/xcschemes/RAD.xcscheme new file mode 100644 index 0000000..57c866d --- /dev/null +++ b/RAD.xcodeproj/xcshareddata/xcschemes/RAD.xcscheme @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RAD/Analytics.swift b/RAD/Analytics.swift new file mode 100644 index 0000000..11b72eb --- /dev/null +++ b/RAD/Analytics.swift @@ -0,0 +1,120 @@ +// +// Analytics.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import AVFoundation + +/// The Analytics object which ties the framework components. +public class Analytics { + public typealias BackgroundFetchCompletion = + (UIBackgroundFetchResult) -> Void + + public var configuration: Configuration { + return scheduler.configuration + } + /// Debug interface for analytics. + public var debugger: AnalyticsDebuggable { + return _debugger + } + private let _debugger: AnalyticsDebugger + private var playerObserver: PlayerObserver? + private let scheduler: NetworkScheduler + + /// Create an analytics object with a custom configuration. + /// Through configuration allows changing various properties, + /// such as batch size of events sent to analytics servers provided as + /// tracking urls from RAD payload embedded in the media files. + /// + /// At initization, the analytics object starts sending data + /// to analytics servers. + /// + /// The default configuration is set to: + /// - *submission time interval*: 1 hour; + /// - *batch size*: 100 events; + /// - *expiration time interval*: 14 days; + /// - *session expiration time interval*: 24 hours; + /// - *request header fields*: [:] - empty dictionary. + /// + /// - Parameter configuration: The configuration object. + public init( + configuration: Configuration = Configuration( + submissionTimeInterval: TimeInterval.hours(1), + batchSize: 100, + expirationTimeInterval: DateComponents(day: 14), + sessionExpirationTimeInterval: TimeInterval.hours(24), + requestHeaderFields: [:]) + ) { + Storage.shared?.load() + _debugger = AnalyticsDebugger() + scheduler = NetworkScheduler(configuration: configuration) + startSendingData() + performSanityCheck() + } + + /// Starts observing a player until the object is deallocated from memory. + /// The instance records data starting with the next item. + /// If the player has only 1 item, the player should be created with no item + /// and replace the item on player after starting the observation. + /// + /// ``` + /// let item = AVPlayerItem(...) + /// player = AVPlayer(playerItem: nil) + /// analytics.observePlayer(player) + /// player.replaceCurrentItem(with: item) + /// ``` + /// + /// - Parameter player: The player to be observed. + public func observePlayer(_ player: AVPlayer) { + playerObserver = PlayerObserver( + player: player, configuration: configuration) + playerObserver?.delegate = _debugger + } + + /// Starts sending data to analytics servers. + /// Sending data is started automatically at object creation. + public func startSendingData() { + scheduler.startScheduling() + } + + /// Stops sending data to analytics servers. + public func stopSendingData() { + scheduler.endScheduling() + } + + /// Starts a task of sending data to analytics servers + /// while the application is in background. Once the task has finished, + /// calls the completion handler on the main queue. + /// + /// - Parameter completion: The completion handler. + public func performBackgroundFetch( + completion: @escaping BackgroundFetchCompletion + ) { + scheduler.executeDataSent { result in + DispatchQueue.main.async { + completion(result) + } + } + } + + // MARK: Private functionality + + private func performSanityCheck() { + OperationQueue.background.addOperations( + [ItemSessionDeactivateOperation(), UnlockObjectsOperation()], + waitUntilFinished: true) + } +} diff --git a/RAD/AnalyticsDebuggable.swift b/RAD/AnalyticsDebuggable.swift new file mode 100644 index 0000000..75f6001 --- /dev/null +++ b/RAD/AnalyticsDebuggable.swift @@ -0,0 +1,51 @@ +// +// AnalyticsDebuggable.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import AVFoundation + +/// An interface which may be inspected to access data for debug purposes. +public protocol AnalyticsDebuggable { + typealias Completion = ([Object]) -> Void + typealias ExtractCompletion = (String) -> Void + + /// Retrieve objects for a specific table. + /// + /// - Parameters: + /// - table: The table to retrieve. + /// - completion: The completion called after finishing retrieval + /// of object for specified table. + func objects(for type: ObjectType, completion: @escaping Completion) + + /// Add an observer to lister for generated objects. + /// + /// - Parameter observer: The observer. + func addListeningObserver(_ observer: ListeningObserver) + /// Removes an observer. It is not required to call the remove. + /// + /// - Parameter observer: The registered observer. + func removeListeningObserver(_ observer: ListeningObserver) + + /// Extract RAD payload from an AVAsset. The RAD payload is formatted + /// to be ready for displaying. + /// The completion handler is called on main queue. + /// + /// - Parameters: + /// - asset: The resource. + /// - completion: The completion handler. + func extractRADPayload( + from asset: AVAsset, completion: @escaping ExtractCompletion) +} diff --git a/RAD/AnalyticsDebugger.swift b/RAD/AnalyticsDebugger.swift new file mode 100644 index 0000000..a24616b --- /dev/null +++ b/RAD/AnalyticsDebugger.swift @@ -0,0 +1,86 @@ +// +// AnalyticsDebugger.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import AVFoundation +import CoreData + +final class AnalyticsDebugger: AnalyticsDebuggable, PlayerObservationDelegate { + private var observersContainer = WeakReferenceContainer() + private var fetcher: DatabaseFetcher? + + init() { + guard let mainContext = Storage.shared?.mainQueueContext else { + return + } + guard let backgroundContext = Storage.shared?.backgroundQueueContext + else { return } + fetcher = DatabaseFetcher( + mainContext: mainContext, backgroundContext: backgroundContext) + } + + /// Fetche objects from internal storage for specified type. + /// + /// - Parameters: + /// - type: The type of objects to fetch. + /// - completion: The handler called upon finishing the retrieval. + func objects(for type: ObjectType, completion: @escaping Completion) { + guard let fetcher = fetcher else { return } + fetcher.fetchObjects(for: type, completion: completion) + } + + /// Register to listening events. + /// + /// - Parameter observer: The observer to register. + func addListeningObserver(_ observer: ListeningObserver) { + observersContainer.append(observer) + } + + /// Unregister from listening events. + /// + /// - Parameter observer: The observer which should be removed. + func removeListeningObserver(_ observer: ListeningObserver) { + observersContainer.remove(observer) + } + + func extractRADPayload( + from asset: AVAsset, + completion: @escaping ExtractCompletion + ) { + let parseOperation = ParseRADPayloadOperation(asset: asset) + let parseJson = ParseJSONOperation() + parseOperation.chainOperation(with: parseJson) + let prettyJson = PrettyJSONOperation() + parseJson.chainOperation(with: prettyJson) + let completion = ClosureInputOperation(closure: completion) + prettyJson.chainOperation(with: completion) + + OperationQueue.background.addOperations( + [parseOperation, parseJson, prettyJson], + waitUntilFinished: false) + OperationQueue.main.addOperation(completion) + } + + // MARK: PlayerObservationDelegate + + func playerDidCreateRanges(with ids: [NSManagedObjectID]) { + fetcher?.fetchObjects(with: ids, completion: { objects in + self.observersContainer.forEach({ observer in + observer?.didGenerateListeningRanges(objects) + }) + }) + } +} diff --git a/RAD/Extensions/CoreData/NSManagedObjectContext+AsyncFetchResult.swift b/RAD/Extensions/CoreData/NSManagedObjectContext+AsyncFetchResult.swift new file mode 100644 index 0000000..2eeb231 --- /dev/null +++ b/RAD/Extensions/CoreData/NSManagedObjectContext+AsyncFetchResult.swift @@ -0,0 +1,27 @@ +// +// NSManagedObjectContext+AsyncFetchResult.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import CoreData + +extension NSManagedObjectContext { + func execute( + _ request: NSPersistentStoreRequest + ) throws -> NSAsynchronousFetchResult? { + let result = try execute(request) + return result as? NSAsynchronousFetchResult + } +} diff --git a/RAD/Extensions/CoreData/NSManagedObjectContext+Delete.swift b/RAD/Extensions/CoreData/NSManagedObjectContext+Delete.swift new file mode 100644 index 0000000..dda5b77 --- /dev/null +++ b/RAD/Extensions/CoreData/NSManagedObjectContext+Delete.swift @@ -0,0 +1,27 @@ +// +// NSManagedObjectContext+Delete.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +extension NSManagedObjectContext { + func deleteObjects(_ objects: [NSManagedObject]) { + objects.forEach({ + self.delete($0) + }) + } +} diff --git a/RAD/Extensions/Foundation/Array+JSONExtension.swift b/RAD/Extensions/Foundation/Array+JSONExtension.swift new file mode 100644 index 0000000..bbcf990 --- /dev/null +++ b/RAD/Extensions/Foundation/Array+JSONExtension.swift @@ -0,0 +1,12 @@ +// +// Array+JSONExtension.swift +// RAD +// +// Created by David Livadaru on 07/06/2018. +// Copyright © 2018 National Public Radio. All rights reserved. +// + +import Foundation + +extension Array: JSON where Element == JSONDictionary { +} diff --git a/RAD/Extensions/Foundation/Bundle+Framework.swift b/RAD/Extensions/Foundation/Bundle+Framework.swift new file mode 100644 index 0000000..229dd56 --- /dev/null +++ b/RAD/Extensions/Foundation/Bundle+Framework.swift @@ -0,0 +1,24 @@ +// +// Bundle+Framework.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension Bundle { + static let framework = Bundle(for: PrivateFrameworkClass.self) +} + +private class PrivateFrameworkClass {} diff --git a/RAD/Extensions/Foundation/Date+Now.swift b/RAD/Extensions/Foundation/Date+Now.swift new file mode 100644 index 0000000..734d5b9 --- /dev/null +++ b/RAD/Extensions/Foundation/Date+Now.swift @@ -0,0 +1,24 @@ +// +// Date+Now.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension Date { + static var now: Date { + return Date() + } +} diff --git a/RAD/Extensions/Foundation/DateComponents+NegativeOperator.swift b/RAD/Extensions/Foundation/DateComponents+NegativeOperator.swift new file mode 100644 index 0000000..d18b346 --- /dev/null +++ b/RAD/Extensions/Foundation/DateComponents+NegativeOperator.swift @@ -0,0 +1,54 @@ +// +// DateComponents+NegativeOperator.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension DateComponents { + static prefix func - (_ value: DateComponents) -> DateComponents { + var minus = DateComponents() + + if let year = value.year { + minus.year = -year + } + + if let month = value.month { + minus.month = -month + } + + if let day = value.day { + minus.day = -day + } + + if let hour = value.hour { + minus.hour = -hour + } + + if let minute = value.minute { + minus.minute = -minute + } + + if let second = value.second { + minus.second = -second + } + + if let nanosecond = value.nanosecond { + minus.nanosecond = -nanosecond + } + + return minus + } +} diff --git a/RAD/Extensions/Foundation/Dictionary+JSONExtension.swift b/RAD/Extensions/Foundation/Dictionary+JSONExtension.swift new file mode 100644 index 0000000..acc1f98 --- /dev/null +++ b/RAD/Extensions/Foundation/Dictionary+JSONExtension.swift @@ -0,0 +1,12 @@ +// +// Dictionary+JSONExtension.swift +// RAD +// +// Created by David Livadaru on 07/06/2018. +// Copyright © 2018 National Public Radio. All rights reserved. +// + +import Foundation + +extension Dictionary: JSON where Key == String, Value == Any { +} diff --git a/RAD/Extensions/Foundation/Dictionary+RawRepresentable.swift b/RAD/Extensions/Foundation/Dictionary+RawRepresentable.swift new file mode 100644 index 0000000..002958d --- /dev/null +++ b/RAD/Extensions/Foundation/Dictionary+RawRepresentable.swift @@ -0,0 +1,30 @@ +// +// Dictionary+RawRepresentable.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension Dictionary { + subscript(key: SubscriptKey) -> Value? + where SubscriptKey.RawValue == Key { + get { + return self[key.rawValue] + } + set { + self[key.rawValue] = newValue + } + } +} diff --git a/RAD/Extensions/Foundation/DispatchQueue+Background.swift b/RAD/Extensions/Foundation/DispatchQueue+Background.swift new file mode 100644 index 0000000..a929efe --- /dev/null +++ b/RAD/Extensions/Foundation/DispatchQueue+Background.swift @@ -0,0 +1,42 @@ +// +// DispatchQueue+Background.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension DispatchQueue { + static let background = DispatchQueue( + label: "npr.rad.background.queue", + qos: DispatchQoS.background, + attributes: [.concurrent]) + + static let playerSessions = DispatchQueue( + label: "npr.rad.playerSessions.queue", + qos: DispatchQoS.userInteractive, + attributes: [.concurrent]) + + static let player = DispatchQueue( + label: "npr.rad.background.player", + qos: DispatchQoS.userInteractive) + + static let timeRange = DispatchQueue( + label: "npr.rad.background.timeRange", + qos: DispatchQoS.utility) + + static let itemSession = DispatchQueue( + label: "npr.rad.background.itemSession", + qos: DispatchQoS.utility) +} diff --git a/RAD/Extensions/Foundation/DispatchQueue+Execution.swift b/RAD/Extensions/Foundation/DispatchQueue+Execution.swift new file mode 100644 index 0000000..f7c692b --- /dev/null +++ b/RAD/Extensions/Foundation/DispatchQueue+Execution.swift @@ -0,0 +1,28 @@ +// +// DispatchQueue+Execution.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension DispatchQueue { + func execute(block: @escaping () -> Void, async: Bool = true) { + if async { + self.async(execute: block) + } else { + self.sync(execute: block) + } + } +} diff --git a/RAD/Extensions/Foundation/Double+String.swift b/RAD/Extensions/Foundation/Double+String.swift new file mode 100644 index 0000000..ca760e3 --- /dev/null +++ b/RAD/Extensions/Foundation/Double+String.swift @@ -0,0 +1,29 @@ +// +// Double+String.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension Double { + /// Converts a string into a double. Checks for NaN and infinity values. + /// + /// - Parameter string: String source. + /// - Returns: Nil or the double value. + static func from(string: String) -> Double? { + guard let value = Double(string) else { return nil } + return value.isFinite ? value : nil + } +} diff --git a/RAD/Extensions/Foundation/FoundationOperation+OptionalDependency.swift b/RAD/Extensions/Foundation/FoundationOperation+OptionalDependency.swift new file mode 100644 index 0000000..7724d38 --- /dev/null +++ b/RAD/Extensions/Foundation/FoundationOperation+OptionalDependency.swift @@ -0,0 +1,26 @@ +// +// FoundationOperation+OptionalDependency.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension Foundation.Operation { + func addDependency(_ operation: Foundation.Operation?) { + if let operation = operation { + addDependency(operation) + } + } +} diff --git a/RAD/Extensions/Foundation/OperationQueue+Background.swift b/RAD/Extensions/Foundation/OperationQueue+Background.swift new file mode 100644 index 0000000..becf04e --- /dev/null +++ b/RAD/Extensions/Foundation/OperationQueue+Background.swift @@ -0,0 +1,50 @@ +// +// OperationQueue+Background.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension OperationQueue { + static let background: OperationQueue = { + let operationQueue = OperationQueue() + operationQueue.underlyingQueue = DispatchQueue.background + return operationQueue + }() + + static let playerSessions: OperationQueue = { + let operationQueue = OperationQueue() + operationQueue.underlyingQueue = DispatchQueue.playerSessions + return operationQueue + }() + + static let player: OperationQueue = { + let operationQueue = OperationQueue() + operationQueue.underlyingQueue = DispatchQueue.player + return operationQueue + }() + + static let itemSession: OperationQueue = { + let operationQueue = OperationQueue() + operationQueue.underlyingQueue = DispatchQueue.itemSession + return operationQueue + }() + + static let timeRange: OperationQueue = { + let operationQueue = OperationQueue() + operationQueue.underlyingQueue = DispatchQueue.timeRange + return operationQueue + }() +} diff --git a/RAD/Extensions/Foundation/RangeReplaceableCollection+Operators.swift b/RAD/Extensions/Foundation/RangeReplaceableCollection+Operators.swift new file mode 100644 index 0000000..1fbf13a --- /dev/null +++ b/RAD/Extensions/Foundation/RangeReplaceableCollection+Operators.swift @@ -0,0 +1,40 @@ +// +// RangeReplaceableCollection+Operators.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension RangeReplaceableCollection { + static func + (_ lhs: Self, _ rhs: Self) -> Self { + var copy = lhs + copy.append(contentsOf: rhs) + return copy + } + + static func + (_ lhs: Self, _ rhs: Element) -> Self { + var copy = lhs + copy.append(rhs) + return copy + } + + static func += (_ lhs: inout Self, _ rhs: Self) { + lhs.append(contentsOf: rhs) + } + + static func += (_ lhs: inout Self, _ rhs: Element) { + lhs.append(rhs) + } +} diff --git a/RAD/Extensions/Foundation/SwiftMathFunctions+Measurement.swift b/RAD/Extensions/Foundation/SwiftMathFunctions+Measurement.swift new file mode 100644 index 0000000..6cb5799 --- /dev/null +++ b/RAD/Extensions/Foundation/SwiftMathFunctions+Measurement.swift @@ -0,0 +1,40 @@ +// +// SwiftMathFunctions+Measurement.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +func floor(_ measurement: Measurement) -> Measurement { + return Measurement( + value: floor(measurement.value), unit: measurement.unit) +} + +func round(_ measurement: Measurement) -> Measurement { + return Measurement( + value: round(measurement.value), unit: measurement.unit) +} + +func roundingMeasurement( + _ measurement: Measurement +) -> Measurement where U: Dimension, U: Roundable { + let roundMeasurement = round(measurement) + let delta = Measurement(value: 1.0, unit: U.lowestUnit()) + if roundMeasurement - measurement < delta { + return roundMeasurement + } else { + return floor(measurement) + } +} diff --git a/RAD/Extensions/Foundation/TimeInterval+Components.swift b/RAD/Extensions/Foundation/TimeInterval+Components.swift new file mode 100644 index 0000000..f14a8f9 --- /dev/null +++ b/RAD/Extensions/Foundation/TimeInterval+Components.swift @@ -0,0 +1,61 @@ +// +// TimeInterval+Components.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +public extension TimeInterval { + /// Create a TimeInterval value based on the provided hours. + /// + /// - Parameter hours: The amount of hours to be converted. + /// - Returns: The amound of seconds as TimeInterval. + static func hours(_ hours: Double) -> TimeInterval { + let hoursMeasurement = Measurement( + value: hours, unit: .hours) + let secondsMeasurement = hoursMeasurement.converted(to: .seconds) + return secondsMeasurement.value + } + + /// Create a TimeInterval value based on the provided minutes. + /// + /// - Parameter minutes: The amount of minutes to be converted. + /// - Returns: The amound of seconds as TimeInterval. + static func minutes(_ minutes: Double) -> TimeInterval { + let minutesMeasurement = Measurement( + value: minutes, unit: .minutes) + let secondsMeasurement = minutesMeasurement.converted(to: .seconds) + return secondsMeasurement.value + } + + /// Create a TimeInterval value based on the provided seconds. + /// + /// - Parameter seconds: The amount of seconds. + /// - Returns: The amount of seconds. + static func seconds(_ seconds: Double) -> TimeInterval { + return seconds + } + + /// Create a TimeInterval value based on the provided milliseconds. + /// + /// - Parameter milliseconds: The amount of milliseconds to be converted. + /// - Returns: The amound of seconds as TimeInterval. + static func milliseconds(_ milliseconds: Double) -> TimeInterval { + let millisecondsMeasurement = Measurement( + value: milliseconds, unit: .milliseconds) + let secondsMeasurement = millisecondsMeasurement.converted(to: .seconds) + return secondsMeasurement.value + } +} diff --git a/RAD/Extensions/Foundation/URLRequest+HttpMethod.swift b/RAD/Extensions/Foundation/URLRequest+HttpMethod.swift new file mode 100644 index 0000000..4cd037e --- /dev/null +++ b/RAD/Extensions/Foundation/URLRequest+HttpMethod.swift @@ -0,0 +1,34 @@ +// +// URLRequest+HttpMethod.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +enum HTTPMethod: String { + case get = "GET", post = "POST", put = "PUT", delete = "DELETE" +} + +extension URLRequest { + var method: HTTPMethod? { + get { + guard let method = httpMethod else { return nil } + return HTTPMethod(rawValue: method) + } + set { + httpMethod = newValue?.rawValue + } + } +} diff --git a/RAD/Extensions/Foundation/URLSessionConfiguration+Configurations.swift b/RAD/Extensions/Foundation/URLSessionConfiguration+Configurations.swift new file mode 100644 index 0000000..d02307e --- /dev/null +++ b/RAD/Extensions/Foundation/URLSessionConfiguration+Configurations.swift @@ -0,0 +1,36 @@ +// +// URLSessionConfiguration+Configurations.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension URLSessionConfiguration { + static var framework: URLSessionConfiguration { + #if TESTING + return `default` + #else + return backgroundConfiguration + #endif + } + + private static let backgroundConfiguration: URLSessionConfiguration = { + let configuration = URLSessionConfiguration.background( + withIdentifier: "npr.rad.backgroundSession") + configuration.sessionSendsLaunchEvents = true + configuration.timeoutIntervalForResource = 60 + return configuration + }() +} diff --git a/RAD/Extensions/Foundation/UnitDuration+Subunits.swift b/RAD/Extensions/Foundation/UnitDuration+Subunits.swift new file mode 100644 index 0000000..3ac3096 --- /dev/null +++ b/RAD/Extensions/Foundation/UnitDuration+Subunits.swift @@ -0,0 +1,26 @@ +// +// UnitDuration+Subunits.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension UnitDuration { + open class var milliseconds: UnitDuration { + return UnitDuration( + symbol: "ms", + converter: UnitConverterLinear(coefficient: 1.0 / 1000.0)) + } +} diff --git a/RAD/Extensions/NSFoundation/NSString+MD5.h b/RAD/Extensions/NSFoundation/NSString+MD5.h new file mode 100644 index 0000000..8985c37 --- /dev/null +++ b/RAD/Extensions/NSFoundation/NSString+MD5.h @@ -0,0 +1,27 @@ +// +// NSString+MD5Category.h +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +#import + +@interface NSString (MD5) + +/** + Computed MD5 from self. + */ +@property (nullable, nonatomic, readonly) NSString *md5; + +@end diff --git a/RAD/Extensions/NSFoundation/NSString+MD5.m b/RAD/Extensions/NSFoundation/NSString+MD5.m new file mode 100644 index 0000000..86eecaf --- /dev/null +++ b/RAD/Extensions/NSFoundation/NSString+MD5.m @@ -0,0 +1,43 @@ +// +// NSString+MD5.m +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +#import "NSString+MD5.h" +#import + +@implementation NSString (MD5) + +- (NSString *)md5 { + const char *buffer = [self UTF8String]; + if (buffer == nil) { + return nil; + } + + unsigned char md5Result[CC_MD5_DIGEST_LENGTH]; + + CC_MD5(buffer, (CC_LONG) strlen(buffer), md5Result); + + NSMutableString *result = + [[NSMutableString alloc] initWithCapacity:CC_MD5_DIGEST_LENGTH]; + + for (int index = 0; index < CC_MD5_DIGEST_LENGTH; ++index) { + [result appendFormat:@"%02x", md5Result[index]]; + } + + return result; +} + +@end diff --git a/RAD/Extensions/Rad/Event+ObjectConvertibleExtension.swift b/RAD/Extensions/Rad/Event+ObjectConvertibleExtension.swift new file mode 100644 index 0000000..1d5aa63 --- /dev/null +++ b/RAD/Extensions/Rad/Event+ObjectConvertibleExtension.swift @@ -0,0 +1,24 @@ +// +// Event+ObjectConvertible.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension Event: ObjectConvertible { + var object: Object { + return json + } +} diff --git a/RAD/Extensions/Rad/ItemSession+ObjectConvertible.swift b/RAD/Extensions/Rad/ItemSession+ObjectConvertible.swift new file mode 100644 index 0000000..698727a --- /dev/null +++ b/RAD/Extensions/Rad/ItemSession+ObjectConvertible.swift @@ -0,0 +1,32 @@ +// +// ItemSession+ObjectConvertible.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension ItemSession: ObjectConvertible { + var object: Object { + var json: Object = [:] + if let count = self.playbackRanges?.count { + json["ranges count"] = count + } + json["is active"] = isActive + if let identifier = sessionId?.identifier { + json["identifier"] = identifier + } + return json + } +} diff --git a/RAD/Extensions/Rad/ItemSessionID+ObjectConvertible.swift b/RAD/Extensions/Rad/ItemSessionID+ObjectConvertible.swift new file mode 100644 index 0000000..716a93e --- /dev/null +++ b/RAD/Extensions/Rad/ItemSessionID+ObjectConvertible.swift @@ -0,0 +1,36 @@ +// +// ItemSessionID+ObjectConvertible.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension ItemSessionID: ObjectConvertible { + var object: Object { + var json: Object = ["isLocked": isLocked] + if let count = self.itemSessions?.count { + json["sessions count"] = count + } + + json["creation date"] = creationIntervalSince1970 + if let identifier = identifier { + json["identifier"] = identifier + } + if let rad = rad { + json["Rad md5"] = rad.md5 + } + return json + } +} diff --git a/RAD/Extensions/Rad/MetadataRelation+ObjectConvertible.swift b/RAD/Extensions/Rad/MetadataRelation+ObjectConvertible.swift new file mode 100644 index 0000000..cc15e47 --- /dev/null +++ b/RAD/Extensions/Rad/MetadataRelation+ObjectConvertible.swift @@ -0,0 +1,24 @@ +// +// MetadataRelation+ObjectConvertible.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension MetadataRelation: ObjectConvertible { + var object: Object { + return json + } +} diff --git a/RAD/Extensions/Rad/Rad+ObjectConvertible.swift b/RAD/Extensions/Rad/Rad+ObjectConvertible.swift new file mode 100644 index 0000000..61be5a6 --- /dev/null +++ b/RAD/Extensions/Rad/Rad+ObjectConvertible.swift @@ -0,0 +1,32 @@ +// +// Rad+ObjectConvertible.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension Rad: ObjectConvertible { + var object: Object { + var json: Object = ["isLocked": isLocked.description] + if let md5 = md5 { + json["md5"] = md5 + } + if let count = itemSessionIds?.count { + json["session IDs count"] = count.description + } + + return json + } +} diff --git a/RAD/Extensions/Rad/RadMetadata+ObjectConvertible.swift b/RAD/Extensions/Rad/RadMetadata+ObjectConvertible.swift new file mode 100644 index 0000000..63beca0 --- /dev/null +++ b/RAD/Extensions/Rad/RadMetadata+ObjectConvertible.swift @@ -0,0 +1,24 @@ +// +// RadMetadata+ObjectConvertible.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension RadMetadata: ObjectConvertible { + var object: Object { + return json + } +} diff --git a/RAD/Extensions/Rad/Range+ObjectConvertible.swift b/RAD/Extensions/Rad/Range+ObjectConvertible.swift new file mode 100644 index 0000000..ca7421b --- /dev/null +++ b/RAD/Extensions/Rad/Range+ObjectConvertible.swift @@ -0,0 +1,30 @@ +// +// Range+ObjectConvertible.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension Range: ObjectConvertible { + var object: Object { + var json: Object = [:] + json["start"] = self.start?.object + json["end"] = self.end?.object + if let identifier = itemSession?.sessionId?.identifier { + json["session identifier"] = identifier + } + return json + } +} diff --git a/RAD/Extensions/Rad/RangeBound+ObjectConvertible.swift b/RAD/Extensions/Rad/RangeBound+ObjectConvertible.swift new file mode 100644 index 0000000..9fd172d --- /dev/null +++ b/RAD/Extensions/Rad/RangeBound+ObjectConvertible.swift @@ -0,0 +1,34 @@ +// +// RangeBound+ObjectConvertible.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension RangeBound: ObjectConvertible { + var object: Object { + var json: Object = [:] + if let time = self.playerTime, + let playerTime = CMTimeFormatter().stringFromTime(time) { + json["playerTime"] = playerTime + } + if let date = self.date { + json.merge(date.object, uniquingKeysWith: { current, _ in + return current + }) + } + return json + } +} diff --git a/RAD/Extensions/Rad/Server+ObjectConvertible.swift b/RAD/Extensions/Rad/Server+ObjectConvertible.swift new file mode 100644 index 0000000..61bb200 --- /dev/null +++ b/RAD/Extensions/Rad/Server+ObjectConvertible.swift @@ -0,0 +1,24 @@ +// +// Server+ObjectConvertible.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension Server: ObjectConvertible { + var object: Object { + return json + } +} diff --git a/RAD/Extensions/Rad/TimezonedDate+ObjectConvertible.swift b/RAD/Extensions/Rad/TimezonedDate+ObjectConvertible.swift new file mode 100644 index 0000000..bbc0bcf --- /dev/null +++ b/RAD/Extensions/Rad/TimezonedDate+ObjectConvertible.swift @@ -0,0 +1,35 @@ +// +// TimezonedDate+ObjectConvertible.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension TimezonedDate: ObjectConvertible { + var object: Object { + var dictionary = self.json + if let event = self.event { + dictionary.merge(event.object, uniquingKeysWith: { current, _ in + return current + }) + } + if metadataRelationsArray.count > 0 { + dictionary["sessions"] = metadataRelationsArray.compactMap({ + $0.sessionId + }) + } + return dictionary + } +} diff --git a/RAD/Extensions/Rad/UnitDuration+Roundable.swift b/RAD/Extensions/Rad/UnitDuration+Roundable.swift new file mode 100644 index 0000000..8ad4a4c --- /dev/null +++ b/RAD/Extensions/Rad/UnitDuration+Roundable.swift @@ -0,0 +1,25 @@ +// +// UnitDuration+Roundable.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension UnitDuration: Roundable { + static func lowestUnit() -> R { + // swiftlint:disable next force_cast + return UnitDuration.milliseconds as! R + } +} diff --git a/RAD/Info.plist b/RAD/Info.plist new file mode 100644 index 0000000..4c0d218 --- /dev/null +++ b/RAD/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSPrincipalClass + + + diff --git a/RAD/Model/CMTimeScale.swift b/RAD/Model/CMTimeScale.swift new file mode 100644 index 0000000..3fd1533 --- /dev/null +++ b/RAD/Model/CMTimeScale.swift @@ -0,0 +1,25 @@ +// +// CMTimeScale.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreMedia + +extension CMTime { + struct TimeScale { + static let podcast: CMTimeScale = 10_000 + } +} diff --git a/RAD/Model/Data Conversion/CMTimeFormatter.swift b/RAD/Model/Data Conversion/CMTimeFormatter.swift new file mode 100644 index 0000000..30759f7 --- /dev/null +++ b/RAD/Model/Data Conversion/CMTimeFormatter.swift @@ -0,0 +1,77 @@ +// +// CMTimeFormatter.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreMedia + +/// A formatter which it handles conversion between String and CMTime. +class CMTimeFormatter { + var timeScale = CMTime.TimeScale.podcast + let componentsSeparator = ":" + + init() {} + + lazy var hoursFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.minimumIntegerDigits = 2 + return numberFormatter + }() + + lazy var minutesFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.minimumIntegerDigits = 2 + return numberFormatter + }() + + lazy var secondsFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.minimumIntegerDigits = 2 + numberFormatter.minimumFractionDigits = 3 + return numberFormatter + }() + + /// Convert CMTime object into a string object. + /// + /// - Parameter time: The time object. + /// - Returns: The CMTime object as string or *nil* if conversion failed. + func stringFromTime(_ time: CMTime) -> String? { + guard let components = TimeComponents( + timeInterval: time.seconds + ) else { return nil } + let numbers = [ + hoursFormatter.string( + from: NSNumber(value: components.hours.value)), + minutesFormatter.string( + from: NSNumber(value: components.minutes.value)), + secondsFormatter.string( + from: NSNumber(value: components.seconds.value)) + ].compactMap({ $0 }) + return numbers.joined(separator: componentsSeparator) + } + + /// Convert a string into a CMTime object. + /// + /// - Parameter string: The string to convert. + /// - Returns: The CMTime object or *nil* if string is not formatted corerctly. + func timeFromString(_ string: String) -> CMTime? { + guard let components = TimeComponents( + string: string, componentsSeparator: componentsSeparator + ) else { return nil } + return CMTime(seconds: components.timeInterval, + preferredTimescale: timeScale) + } +} diff --git a/RAD/Model/Data Conversion/ObjectConvertible.swift b/RAD/Model/Data Conversion/ObjectConvertible.swift new file mode 100644 index 0000000..ffd69e9 --- /dev/null +++ b/RAD/Model/Data Conversion/ObjectConvertible.swift @@ -0,0 +1,22 @@ +// +// ObjectConvertible.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +protocol ObjectConvertible { + var object: Object { get } +} diff --git a/RAD/Model/DatabaseFetcher.swift b/RAD/Model/DatabaseFetcher.swift new file mode 100644 index 0000000..e4609af --- /dev/null +++ b/RAD/Model/DatabaseFetcher.swift @@ -0,0 +1,123 @@ +// +// DatabaseFetcher.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import CoreData + +struct DatabaseFetcher { + typealias Completion = ([Object]) -> Void + + private let mainContext: NSManagedObjectContext + private let backgroundContext: NSManagedObjectContext + + init( + mainContext: NSManagedObjectContext, + backgroundContext: NSManagedObjectContext + ) { + self.mainContext = mainContext + self.backgroundContext = backgroundContext + } + + /// Fetch objects from database for specified type. + /// + /// - Parameters: + /// - type: The type of objects to fetch. + /// - completion: The handler called upon finihsing the fetch. + func fetchObjects( + for type: ObjectType, completion: @escaping Completion + ) { + switch type { + case .event: + executeFetch(for: Event.self, completion: completion) + case .itemSession: + executeFetch(for: ItemSession.self, completion: completion) + case .itemSessionId: + executeFetch(for: ItemSessionID.self, completion: completion) + case .rad: + executeFetch(for: Rad.self, completion: completion) + case .radMetadata: + executeFetch(for: RadMetadata.self, completion: completion) + case .metadataRelation: + executeFetch(for: MetadataRelation.self, completion: completion) + case .range: + executeFetch(for: Range.self, completion: completion) + case .rangeBound: + executeFetch(for: RangeBound.self, completion: completion) + case .server: + executeFetch(for: Server.self, completion: completion) + case .timezonedDate: + executeFetch(for: TimezonedDate.self, completion: completion) + } + } + + /// Fetch objects with specified object ids. + /// + /// - Parameters: + /// - ids: The NSManagedObjectIDs. + /// - completion: The completion handler. + func fetchObjects( + with ids: [NSManagedObjectID], completion: @escaping Completion + ) { + self.mainContext.perform { + let objects: [Object] = ids.compactMap({ + let fetchedObject = self.mainContext.object(with: $0) + let convertibleObject = fetchedObject as? ObjectConvertible + return convertibleObject?.object + }) + completion(objects) + } + } + + // MARK: Private functionality + + /// Peform a fetch for a generic database type. + /// + /// - Parameters: + /// - type: The type to fetch. + /// - completion: The completion handler. + private func executeFetch( + for type: T.Type, completion: @escaping Completion + ) { + let fetchOperation = FetchOperation(context: backgroundContext) + fetchOperation.completionBlock = { [weak fetchOperation] in + if fetchOperation?.finishError != nil { + completion([]) + } + } + + let transferOperation = ContextTransferOperation( + context: backgroundContext) + fetchOperation.chainOperation(with: transferOperation) + + let contextFetchOperation = ContextFetchOperation( + context: mainContext) + transferOperation.chainOperation(with: contextFetchOperation) + + let conversionOperation = ObjectConversionOperation( + context: mainContext) + contextFetchOperation.chainOperation(with: conversionOperation) + + let closureOperation = ClosureInputOperation<[Object]>( + closure: completion) + conversionOperation.chainOperation(with: closureOperation) + + OperationQueue.background.addOperations( + [fetchOperation, transferOperation, contextFetchOperation], + waitUntilFinished: false) + OperationQueue.main.addOperations( + [conversionOperation, closureOperation], waitUntilFinished: false) + } +} diff --git a/RAD/Model/Entities/Batch.swift b/RAD/Model/Entities/Batch.swift new file mode 100644 index 0000000..d820c96 --- /dev/null +++ b/RAD/Model/Entities/Batch.swift @@ -0,0 +1,49 @@ +// +// Batch.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct Batch { + let server: Server + var groups: [MetadataGroup] + + var datesCount: Int { + return groups.reduce(0, { $0 + $1.dates.count }) + } + + var dates: [TimezonedDate] { + return groups.flatMap({ $0.dates }) + } + + var json: JSONDictionary { + var json: JSONDictionary = [:] + let metadataObjects: JSONArray = groups.compactMap({ + guard let count = $0.metadata.dates?.count, count > 0 + else { return nil } + var json = $0.metadata.json + json[RadMetadata.JSONProperty.events] = $0.dates.map({ $0.json }) + return json + }) + json[Server.JSONProperty.audioSessions] = metadataObjects + return json + } + + init(server: Server, groups: [MetadataGroup] = []) { + self.server = server + self.groups = groups + } +} diff --git a/RAD/Model/Entities/Configuration.swift b/RAD/Model/Entities/Configuration.swift new file mode 100644 index 0000000..89cd67a --- /dev/null +++ b/RAD/Model/Entities/Configuration.swift @@ -0,0 +1,51 @@ +// +// Configuration.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +public class Configuration { + /// The time interval upon which events are sent to analytics server. + public let submissionTimeInterval: TimeInterval + + /// The maximum number of events to be sent in a request. + public let batchSize: UInt + + /// The time after which stored local events will be deleted + /// without sending them to server. + public let expirationTimeInterval: DateComponents + + /// The time after which the session expires. + public let sessionExpirationTimeInterval: TimeInterval + + /// A dictionary which will be set as header fields to each + /// request created to each tracking url. + public let requestHeaderFields: [String: String] + + public init( + submissionTimeInterval: TimeInterval, + batchSize: UInt, + expirationTimeInterval: DateComponents, + sessionExpirationTimeInterval: TimeInterval, + requestHeaderFields: [String: String] + ) { + self.submissionTimeInterval = submissionTimeInterval + self.batchSize = batchSize + self.expirationTimeInterval = expirationTimeInterval + self.sessionExpirationTimeInterval = sessionExpirationTimeInterval + self.requestHeaderFields = requestHeaderFields + } +} diff --git a/RAD/Model/Entities/ConversionResult.swift b/RAD/Model/Entities/ConversionResult.swift new file mode 100644 index 0000000..a87ca16 --- /dev/null +++ b/RAD/Model/Entities/ConversionResult.swift @@ -0,0 +1,23 @@ +// +// ConversionResult.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct ConversionResult { + let batch: Batch + let request: URLRequest +} diff --git a/RAD/Model/Entities/CoreData/Event+Convenience.swift b/RAD/Model/Entities/CoreData/Event+Convenience.swift new file mode 100644 index 0000000..8940bab --- /dev/null +++ b/RAD/Model/Entities/CoreData/Event+Convenience.swift @@ -0,0 +1,50 @@ +// +// Event+JsonInitialization.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +extension Event { + var timezonedDates: [TimezonedDate] { + return dates?.allObjects as? [TimezonedDate] ?? [] + } + + /// Creates an instance of Event. + /// + /// - Parameters: + /// - json: A json which contains Event's data. + /// - context: The context which is used to create the object. + convenience init?(json: JSONDictionary, + context: NSManagedObjectContext) { + var copy = json + + guard let eventTime = copy[JSONProperty.eventTime] as? String + else { return nil } + guard let time = CMTimeFormatter().timeFromString(eventTime) + else { return nil } + + guard let entityDescription = NSEntityDescription.entity(forEntityName: "Event", + in: context) + else { return nil } + self.init(entity: entityDescription, insertInto: context) + + self.eventTime = eventTime + self.cmEventTime = time + copy[JSONProperty.eventTime] = nil + otherFields = copy as NSDictionary + } +} diff --git a/RAD/Model/Entities/CoreData/Event+CoreDataClass.swift b/RAD/Model/Entities/CoreData/Event+CoreDataClass.swift new file mode 100644 index 0000000..7f921e8 --- /dev/null +++ b/RAD/Model/Entities/CoreData/Event+CoreDataClass.swift @@ -0,0 +1,35 @@ +// +// Event+CoreDataClass.swift +// RADTestApplication +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData +import CoreMedia + +@objc(Event) +class Event: GenericObjectContainer { + var cmEventTime: CMTime? + + enum JSONProperty: String, CodingKey { + case eventTime, timestamp + } + + override var json: JSONDictionary { + var copy = super.json + copy[JSONProperty.eventTime] = eventTime + return copy + } +} diff --git a/RAD/Model/Entities/CoreData/Event+CoreDataProperties.swift b/RAD/Model/Entities/CoreData/Event+CoreDataProperties.swift new file mode 100644 index 0000000..51836ab --- /dev/null +++ b/RAD/Model/Entities/CoreData/Event+CoreDataProperties.swift @@ -0,0 +1,28 @@ +// +// Event+CoreDataProperties.swift +// RADTestApplication +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +extension Event { + @nonobjc class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "Event") + } + + @NSManaged var eventTime: String? + @NSManaged var dates: NSSet? +} diff --git a/RAD/Model/Entities/CoreData/GenericObjectContainer.swift b/RAD/Model/Entities/CoreData/GenericObjectContainer.swift new file mode 100644 index 0000000..43632fe --- /dev/null +++ b/RAD/Model/Entities/CoreData/GenericObjectContainer.swift @@ -0,0 +1,33 @@ +// +// GenericObjectContainer.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +/// A subclass which simplifies the usage of otherFields property. +class GenericObjectContainer: NSManagedObject { + @NSManaged var md5: String? + @NSManaged var otherFields: NSDictionary? + + var json: JSONDictionary { + return otherFields as? JSONDictionary ?? [:] + } + + func updateOtherFields(_ closure: (_ json: JSONDictionary?) -> JSONDictionary?) { + otherFields = closure(json) as NSDictionary? + } +} diff --git a/RAD/Model/Entities/CoreData/ItemSession+CoreDataClass.swift b/RAD/Model/Entities/CoreData/ItemSession+CoreDataClass.swift new file mode 100644 index 0000000..7439b53 --- /dev/null +++ b/RAD/Model/Entities/CoreData/ItemSession+CoreDataClass.swift @@ -0,0 +1,16 @@ +// +// ItemSession+CoreDataClass.swift +// RAD +// +// Created by David Livadaru on 26/10/2018. +// Copyright © 2018 NPR. All rights reserved. +// +// + +import Foundation +import CoreData + +@objc(ItemSession) +class ItemSession: NSManagedObject { + +} diff --git a/RAD/Model/Entities/CoreData/ItemSession+CoreDataProperties.swift b/RAD/Model/Entities/CoreData/ItemSession+CoreDataProperties.swift new file mode 100644 index 0000000..b365c1d --- /dev/null +++ b/RAD/Model/Entities/CoreData/ItemSession+CoreDataProperties.swift @@ -0,0 +1,40 @@ +// +// ItemSession+CoreDataProperties.swift +// RAD +// +// Created by David Livadaru on 26/10/2018. +// Copyright © 2018 NPR. All rights reserved. +// +// + +import Foundation +import CoreData + +extension ItemSession { + + @nonobjc class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "ItemSession") + } + + @NSManaged var isActive: Bool + @NSManaged var playbackRanges: NSSet? + @NSManaged var sessionId: ItemSessionID? + +} + +// MARK: Generated accessors for playbackRanges +extension ItemSession { + + @objc(addPlaybackRangesObject:) + @NSManaged func addToPlaybackRanges(_ value: Range) + + @objc(removePlaybackRangesObject:) + @NSManaged func removeFromPlaybackRanges(_ value: Range) + + @objc(addPlaybackRanges:) + @NSManaged func addToPlaybackRanges(_ values: NSSet) + + @objc(removePlaybackRanges:) + @NSManaged func removeFromPlaybackRanges(_ values: NSSet) + +} diff --git a/RAD/Model/Entities/CoreData/ItemSessionID+CoreDataClass.swift b/RAD/Model/Entities/CoreData/ItemSessionID+CoreDataClass.swift new file mode 100644 index 0000000..8a9a1c1 --- /dev/null +++ b/RAD/Model/Entities/CoreData/ItemSessionID+CoreDataClass.swift @@ -0,0 +1,16 @@ +// +// ItemSessionID+CoreDataClass.swift +// RAD +// +// Created by David Livadaru on 26/10/2018. +// Copyright © 2018 NPR. All rights reserved. +// +// + +import Foundation +import CoreData + +@objc(ItemSessionID) +class ItemSessionID: NSManagedObject { + +} diff --git a/RAD/Model/Entities/CoreData/ItemSessionID+CoreDataProperties.swift b/RAD/Model/Entities/CoreData/ItemSessionID+CoreDataProperties.swift new file mode 100644 index 0000000..58ebf7c --- /dev/null +++ b/RAD/Model/Entities/CoreData/ItemSessionID+CoreDataProperties.swift @@ -0,0 +1,42 @@ +// +// ItemSessionID+CoreDataProperties.swift +// RAD +// +// Created by David Livadaru on 26/10/2018. +// Copyright © 2018 NPR. All rights reserved. +// +// + +import Foundation +import CoreData + +extension ItemSessionID { + + @nonobjc class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "ItemSessionID") + } + + @NSManaged var creationIntervalSince1970: Double + @NSManaged var identifier: String? + @NSManaged var isLocked: Bool + @NSManaged var itemSessions: NSSet? + @NSManaged var rad: Rad? + +} + +// MARK: Generated accessors for itemSessions +extension ItemSessionID { + + @objc(addItemSessionsObject:) + @NSManaged func addToItemSessions(_ value: ItemSession) + + @objc(removeItemSessionsObject:) + @NSManaged func removeFromItemSessions(_ value: ItemSession) + + @objc(addItemSessions:) + @NSManaged func addToItemSessions(_ values: NSSet) + + @objc(removeItemSessions:) + @NSManaged func removeFromItemSessions(_ values: NSSet) + +} diff --git a/RAD/Model/Entities/CoreData/MetadataRelation+Convenience.swift b/RAD/Model/Entities/CoreData/MetadataRelation+Convenience.swift new file mode 100644 index 0000000..23051c9 --- /dev/null +++ b/RAD/Model/Entities/CoreData/MetadataRelation+Convenience.swift @@ -0,0 +1,34 @@ +// +// MetadataRelation+Convenience.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension MetadataRelation { + enum JSONProperty: String, CodingKey { + case sessionId, events + } + + var timezonedDates: [TimezonedDate] { + return dates?.allObjects as? [TimezonedDate] ?? [] + } + + var json: JSONDictionary { + var json = radMetadata?.json ?? [:] + json[JSONProperty.sessionId] = sessionId + return json + } +} diff --git a/RAD/Model/Entities/CoreData/MetadataRelation+CoreDataClass.swift b/RAD/Model/Entities/CoreData/MetadataRelation+CoreDataClass.swift new file mode 100644 index 0000000..da39e5f --- /dev/null +++ b/RAD/Model/Entities/CoreData/MetadataRelation+CoreDataClass.swift @@ -0,0 +1,16 @@ +// +// MetadataRelation+CoreDataClass.swift +// RAD +// +// Created by David Livadaru on 26/10/2018. +// Copyright © 2018 NPR. All rights reserved. +// +// + +import Foundation +import CoreData + +@objc(MetadataRelation) +class MetadataRelation: NSManagedObject { + +} diff --git a/RAD/Model/Entities/CoreData/MetadataRelation+CoreDataProperties.swift b/RAD/Model/Entities/CoreData/MetadataRelation+CoreDataProperties.swift new file mode 100644 index 0000000..2489988 --- /dev/null +++ b/RAD/Model/Entities/CoreData/MetadataRelation+CoreDataProperties.swift @@ -0,0 +1,41 @@ +// +// MetadataRelation+CoreDataProperties.swift +// RAD +// +// Created by David Livadaru on 26/10/2018. +// Copyright © 2018 NPR. All rights reserved. +// +// + +import Foundation +import CoreData + +extension MetadataRelation { + + @nonobjc class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "MetadataRelation") + } + + @NSManaged var sessionId: String? + @NSManaged var dates: NSSet? + @NSManaged var radMetadata: RadMetadata? + @NSManaged var server: Server? + +} + +// MARK: Generated accessors for dates +extension MetadataRelation { + + @objc(addDatesObject:) + @NSManaged func addToDates(_ value: TimezonedDate) + + @objc(removeDatesObject:) + @NSManaged func removeFromDates(_ value: TimezonedDate) + + @objc(addDates:) + @NSManaged func addToDates(_ values: NSSet) + + @objc(removeDates:) + @NSManaged func removeFromDates(_ values: NSSet) + +} diff --git a/RAD/Model/Entities/CoreData/Rad+CoreDataClass.swift b/RAD/Model/Entities/CoreData/Rad+CoreDataClass.swift new file mode 100644 index 0000000..a506cce --- /dev/null +++ b/RAD/Model/Entities/CoreData/Rad+CoreDataClass.swift @@ -0,0 +1,16 @@ +// +// Rad+CoreDataClass.swift +// RAD +// +// Created by David Livadaru on 26/10/2018. +// Copyright © 2018 NPR. All rights reserved. +// +// + +import Foundation +import CoreData + +@objc(Rad) +class Rad: NSManagedObject { + +} diff --git a/RAD/Model/Entities/CoreData/Rad+CoreDataProperties.swift b/RAD/Model/Entities/CoreData/Rad+CoreDataProperties.swift new file mode 100644 index 0000000..5e1a5f7 --- /dev/null +++ b/RAD/Model/Entities/CoreData/Rad+CoreDataProperties.swift @@ -0,0 +1,41 @@ +// +// Rad+CoreDataProperties.swift +// RAD +// +// Created by David Livadaru on 26/10/2018. +// Copyright © 2018 NPR. All rights reserved. +// +// + +import Foundation +import CoreData + +extension Rad { + + @nonobjc class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "Rad") + } + + @NSManaged var isLocked: Bool + @NSManaged var json: String? + @NSManaged var md5: String? + @NSManaged var itemSessionIds: NSSet? + +} + +// MARK: Generated accessors for itemSessionIds +extension Rad { + + @objc(addItemSessionIdsObject:) + @NSManaged func addToItemSessionIds(_ value: ItemSessionID) + + @objc(removeItemSessionIdsObject:) + @NSManaged func removeFromItemSessionIds(_ value: ItemSessionID) + + @objc(addItemSessionIds:) + @NSManaged func addToItemSessionIds(_ values: NSSet) + + @objc(removeItemSessionIds:) + @NSManaged func removeFromItemSessionIds(_ values: NSSet) + +} diff --git a/RAD/Model/Entities/CoreData/RadMetadata+Convenience.swift b/RAD/Model/Entities/CoreData/RadMetadata+Convenience.swift new file mode 100644 index 0000000..85f7e1e --- /dev/null +++ b/RAD/Model/Entities/CoreData/RadMetadata+Convenience.swift @@ -0,0 +1,44 @@ +// +// RadMetadata+JsonInitialization.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +extension RadMetadata { + var metadataRelationsArray: [MetadataRelation] { + return metadataRelations?.allObjects as? [MetadataRelation] ?? [] + } + + /// Creates an instance of RadMetadata. + /// + /// - Parameters: + /// - json: A json which contains RadMetadata's data. + /// - context: The context which is used to create the object. + convenience init?(json: JSONDictionary, + context: NSManagedObjectContext) { + guard json[JSONProperty.events] != nil else { return nil } + + guard let entityDescription = NSEntityDescription.entity(forEntityName: "RadMetadata", + in: context) + else { return nil } + self.init(entity: entityDescription, insertInto: context) + + var copy = json + copy[JSONProperty.events] = nil + otherFields = copy as NSDictionary + } +} diff --git a/RAD/Model/Entities/CoreData/RadMetadata+CoreDataClass.swift b/RAD/Model/Entities/CoreData/RadMetadata+CoreDataClass.swift new file mode 100644 index 0000000..52b4511 --- /dev/null +++ b/RAD/Model/Entities/CoreData/RadMetadata+CoreDataClass.swift @@ -0,0 +1,26 @@ +// +// RadMetadata+CoreDataClass.swift +// RADTestApplication +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +@objc(RadMetadata) +class RadMetadata: GenericObjectContainer { + enum JSONProperty: String { + case events, podcastId, episodeId, remoteAudioData + } +} diff --git a/RAD/Model/Entities/CoreData/RadMetadata+CoreDataProperties.swift b/RAD/Model/Entities/CoreData/RadMetadata+CoreDataProperties.swift new file mode 100644 index 0000000..2293c54 --- /dev/null +++ b/RAD/Model/Entities/CoreData/RadMetadata+CoreDataProperties.swift @@ -0,0 +1,43 @@ +// +// RadMetadata+CoreDataProperties.swift +// RADTestApplication +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +extension RadMetadata { + @nonobjc class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "RadMetadata") + } + + @NSManaged var metadataRelations: NSSet? +} + +// MARK: Generated accessors for events +extension RadMetadata { + @objc(addEventsObject:) + @NSManaged func addToMetadataRelations(_ value: MetadataRelation) + + @objc(removeEventsObject:) + @NSManaged func removeFromMetadataRelations( + _ value: MetadataRelation) + + @objc(addEvents:) + @NSManaged func addToMetadataRelations(_ values: NSSet) + + @objc(removeEvents:) + @NSManaged func removeFromMetadataRelations(_ values: NSSet) +} diff --git a/RAD/Model/Entities/CoreData/Range+CMTimeExtension.swift b/RAD/Model/Entities/CoreData/Range+CMTimeExtension.swift new file mode 100644 index 0000000..b0b591c --- /dev/null +++ b/RAD/Model/Entities/CoreData/Range+CMTimeExtension.swift @@ -0,0 +1,28 @@ +// +// Range+CMTime.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreMedia + +extension Range { + func containsTime(_ time: CMTime) -> Bool { + guard let start = start?.playerTime else { return false } + guard let end = end?.playerTime else { return false } + + return start <= time && time <= end + } +} diff --git a/RAD/Model/Entities/CoreData/Range+CoreDataClass.swift b/RAD/Model/Entities/CoreData/Range+CoreDataClass.swift new file mode 100644 index 0000000..d9fa0cb --- /dev/null +++ b/RAD/Model/Entities/CoreData/Range+CoreDataClass.swift @@ -0,0 +1,16 @@ +// +// Range+CoreDataClass.swift +// RAD +// +// Created by David Livadaru on 26/10/2018. +// Copyright © 2018 NPR. All rights reserved. +// +// + +import Foundation +import CoreData + +@objc(Range) +class Range: NSManagedObject { + +} diff --git a/RAD/Model/Entities/CoreData/Range+CoreDataProperties.swift b/RAD/Model/Entities/CoreData/Range+CoreDataProperties.swift new file mode 100644 index 0000000..230d6ec --- /dev/null +++ b/RAD/Model/Entities/CoreData/Range+CoreDataProperties.swift @@ -0,0 +1,23 @@ +// +// Range+CoreDataProperties.swift +// RAD +// +// Created by David Livadaru on 26/10/2018. +// Copyright © 2018 NPR. All rights reserved. +// +// + +import Foundation +import CoreData + +extension Range { + + @nonobjc class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "Range") + } + + @NSManaged var end: RangeBound? + @NSManaged var itemSession: ItemSession? + @NSManaged var start: RangeBound? + +} diff --git a/RAD/Model/Entities/CoreData/RangeBound+CoreDataClass.swift b/RAD/Model/Entities/CoreData/RangeBound+CoreDataClass.swift new file mode 100644 index 0000000..b7ccfc6 --- /dev/null +++ b/RAD/Model/Entities/CoreData/RangeBound+CoreDataClass.swift @@ -0,0 +1,24 @@ +// +// RangeBound+CoreDataClass.swift +// RADTestApplication +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +@objc(RangeBound) +class RangeBound: NSManagedObject { + +} diff --git a/RAD/Model/Entities/CoreData/RangeBound+CoreDataProperties.swift b/RAD/Model/Entities/CoreData/RangeBound+CoreDataProperties.swift new file mode 100644 index 0000000..deb30f1 --- /dev/null +++ b/RAD/Model/Entities/CoreData/RangeBound+CoreDataProperties.swift @@ -0,0 +1,46 @@ +// +// RangeBound+CoreDataProperties.swift +// RADTestApplication +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData +import CoreMedia + +extension RangeBound { + @nonobjc class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "RangeBound") + } + + var playerTime: CMTime? { + get { + return playerTimeValue?.timeValue + } + set { + guard let newValue = newValue else { + playerTimeValue = nil + return + } + + playerTimeValue = NSValue(time: newValue) + } + } + + @NSManaged var playerTimeValue: NSValue? + @NSManaged var date: TimezonedDate? + @NSManaged var rangeForEnd: Range? + @NSManaged var rangeForStart: Range? + +} diff --git a/RAD/Model/Entities/CoreData/Server+Convenience.swift b/RAD/Model/Entities/CoreData/Server+Convenience.swift new file mode 100644 index 0000000..23b4a2a --- /dev/null +++ b/RAD/Model/Entities/CoreData/Server+Convenience.swift @@ -0,0 +1,48 @@ +// +// Server+JsonInitialization.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +extension Server { + var trackingURI: URL? { + guard let string = trackingUrl else { return nil } + return URL(string: string) + } + + var metadataRelationsArray: [MetadataRelation] { + return metadataRelations?.allObjects as? [MetadataRelation] ?? [] + } + + /// Creates an instance of Server. + /// + /// - Parameters: + /// - urlString: Url as a string object. + /// - context: The context which is used to create the object. + convenience init?(urlString: String, + context: NSManagedObjectContext) { + guard URL(string: urlString) != nil else { return nil } + + guard let entityDescription = NSEntityDescription.entity( + forEntityName: "Server", in: context) + else { return nil } + + self.init(entity: entityDescription, insertInto: context) + + trackingUrl = urlString + } +} diff --git a/RAD/Model/Entities/CoreData/Server+CoreDataClass.swift b/RAD/Model/Entities/CoreData/Server+CoreDataClass.swift new file mode 100644 index 0000000..b834eae --- /dev/null +++ b/RAD/Model/Entities/CoreData/Server+CoreDataClass.swift @@ -0,0 +1,32 @@ +// +// Server+CoreDataClass.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +@objc(Server) +class Server: NSManagedObject { + enum JSONProperty: String { + case trackingUrl, trackingUrls, audioSessions + } + + var json: JSONDictionary { + var json: JSONDictionary = [:] + json[JSONProperty.trackingUrl] = trackingUrl + return json + } +} diff --git a/RAD/Model/Entities/CoreData/Server+CoreDataProperties.swift b/RAD/Model/Entities/CoreData/Server+CoreDataProperties.swift new file mode 100644 index 0000000..3cb3968 --- /dev/null +++ b/RAD/Model/Entities/CoreData/Server+CoreDataProperties.swift @@ -0,0 +1,44 @@ +// +// Server+CoreDataProperties.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +extension Server { + @nonobjc class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "Server") + } + + @NSManaged var trackingUrl: String? + @NSManaged var metadataRelations: NSSet? +} + +// MARK: Generated accessors for metadataObjects +extension Server { + @objc(addEventsObject:) + @NSManaged func addToMetadataRelations(_ value: MetadataRelation) + + @objc(removeEventsObject:) + @NSManaged func removeFromMetadataRelations( + _ value: MetadataRelation) + + @objc(addEvents:) + @NSManaged func addToMetadataRelations(_ values: NSSet) + + @objc(removeEvents:) + @NSManaged func removeFromMetadataRelations(_ values: NSSet) +} diff --git a/RAD/Model/Entities/CoreData/TimezonedDate+Convenience.swift b/RAD/Model/Entities/CoreData/TimezonedDate+Convenience.swift new file mode 100644 index 0000000..a7a0e7f --- /dev/null +++ b/RAD/Model/Entities/CoreData/TimezonedDate+Convenience.swift @@ -0,0 +1,41 @@ +// +// TimezonedDate+Convenience.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension TimezonedDate { + enum JSONProperty: String, CodingKey { + case timestamp + } + + var metadataRelationsArray: [MetadataRelation] { + return metadataRelations?.allObjects as? [MetadataRelation] ?? [] + } + + var json: JSONDictionary { + var json = event?.json ?? [:] + + let dateFormatter = ISO8601DateFormatter() + let date = Date( + timeIntervalSince1970: intervalSince1970) + dateFormatter.timeZone = TimeZone( + secondsFromGMT: Int(timezoneOffset)) + + json[JSONProperty.timestamp] = dateFormatter.string(from: date) + return json + } +} diff --git a/RAD/Model/Entities/CoreData/TimezonedDate+CoreDataClass.swift b/RAD/Model/Entities/CoreData/TimezonedDate+CoreDataClass.swift new file mode 100644 index 0000000..f55e09b --- /dev/null +++ b/RAD/Model/Entities/CoreData/TimezonedDate+CoreDataClass.swift @@ -0,0 +1,16 @@ +// +// TimezonedDate+CoreDataClass.swift +// RAD +// +// Created by David Livadaru on 26/10/2018. +// Copyright © 2018 NPR. All rights reserved. +// +// + +import Foundation +import CoreData + +@objc(TimezonedDate) +class TimezonedDate: NSManagedObject { + +} diff --git a/RAD/Model/Entities/CoreData/TimezonedDate+CoreDataProperties.swift b/RAD/Model/Entities/CoreData/TimezonedDate+CoreDataProperties.swift new file mode 100644 index 0000000..f52c621 --- /dev/null +++ b/RAD/Model/Entities/CoreData/TimezonedDate+CoreDataProperties.swift @@ -0,0 +1,42 @@ +// +// TimezonedDate+CoreDataProperties.swift +// RAD +// +// Created by David Livadaru on 26/10/2018. +// Copyright © 2018 NPR. All rights reserved. +// +// + +import Foundation +import CoreData + +extension TimezonedDate { + + @nonobjc class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "TimezonedDate") + } + + @NSManaged var intervalSince1970: Double + @NSManaged var timezoneOffset: Int64 + @NSManaged var event: Event? + @NSManaged var metadataRelations: NSSet? + @NSManaged var rangeBound: RangeBound? + +} + +// MARK: Generated accessors for metadataRelations +extension TimezonedDate { + + @objc(addMetadataRelationsObject:) + @NSManaged func addToMetadataRelations(_ value: MetadataRelation) + + @objc(removeMetadataRelationsObject:) + @NSManaged func removeFromMetadataRelations(_ value: MetadataRelation) + + @objc(addMetadataRelations:) + @NSManaged func addToMetadataRelations(_ values: NSSet) + + @objc(removeMetadataRelations:) + @NSManaged func removeFromMetadataRelations(_ values: NSSet) + +} diff --git a/RAD/Model/Entities/Errors/FetchError.swift b/RAD/Model/Entities/Errors/FetchError.swift new file mode 100644 index 0000000..b5d80e1 --- /dev/null +++ b/RAD/Model/Entities/Errors/FetchError.swift @@ -0,0 +1,25 @@ +// +// FetchError.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// The errors which may occur during the fetch of objects. +/// +/// - *entityNotFound*: The entity was not found in the database model. +enum FetchError: Error { + case entityNotFound +} diff --git a/RAD/Model/Entities/Errors/InputError.swift b/RAD/Model/Entities/Errors/InputError.swift new file mode 100644 index 0000000..50db669 --- /dev/null +++ b/RAD/Model/Entities/Errors/InputError.swift @@ -0,0 +1,26 @@ +// +// InputError.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// The errors which may ocurr during the process of input data. +/// +/// - *requiredDataNotAvailable*: The input data is not available. +/// - *inconsistentData*: The data which was provided is not consistent. +enum InputError: Error { + case requiredDataNotAvailable, inconsistentData +} diff --git a/RAD/Model/Entities/Errors/OutputError.swift b/RAD/Model/Entities/Errors/OutputError.swift new file mode 100644 index 0000000..11df9c7 --- /dev/null +++ b/RAD/Model/Entities/Errors/OutputError.swift @@ -0,0 +1,25 @@ +// +// OutputError.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// The errors which may ocurr during the computation of an output. +/// +/// - *computationError*: Generic error for computing output. +enum OutputError: Error { + case computationError +} diff --git a/RAD/Model/Entities/Errors/ParseError.swift b/RAD/Model/Entities/Errors/ParseError.swift new file mode 100644 index 0000000..1cbabee --- /dev/null +++ b/RAD/Model/Entities/Errors/ParseError.swift @@ -0,0 +1,27 @@ +// +// ParseError.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// The error which may ocurr during parsing. +/// +/// - *radPayloadNotFound*: The RAD payload was not found. +/// - *unableToParseJson*: The json could not be read or parsed due to +/// either bad format or type mismatching. +enum ParseError: Error { + case radPayloadNotFound, unableToParseJson +} diff --git a/RAD/Model/Entities/EventRegistration.swift b/RAD/Model/Entities/EventRegistration.swift new file mode 100644 index 0000000..c20a852 --- /dev/null +++ b/RAD/Model/Entities/EventRegistration.swift @@ -0,0 +1,26 @@ +// +// EventRegistration.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreMedia + +struct EventRegistration { + typealias Closure = () -> Void + + let time: CMTime + let closure: Closure +} diff --git a/RAD/Model/Entities/FilterResult.swift b/RAD/Model/Entities/FilterResult.swift new file mode 100644 index 0000000..baa751a --- /dev/null +++ b/RAD/Model/Entities/FilterResult.swift @@ -0,0 +1,24 @@ +// +// FilterResult.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct FilterResult { + let dates: [TimezonedDate] + let saved: Set + let unused: Set +} diff --git a/RAD/Model/Entities/JSON.swift b/RAD/Model/Entities/JSON.swift new file mode 100644 index 0000000..5d9f7be --- /dev/null +++ b/RAD/Model/Entities/JSON.swift @@ -0,0 +1,21 @@ +// +// JSON.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +public typealias JSONArray = [JSONDictionary] +public typealias JSONDictionary = [String: Any] diff --git a/RAD/Model/Entities/JSONObject.swift b/RAD/Model/Entities/JSONObject.swift new file mode 100644 index 0000000..4c3214a --- /dev/null +++ b/RAD/Model/Entities/JSONObject.swift @@ -0,0 +1,17 @@ +// +// JSONObject.swift +// RAD +// +// Created by David Livadaru on 07/06/2018. +// Copyright © 2018 National Public Radio. All rights reserved. +// + +import Foundation + +public class JSONObject { + public let json: JSONType + + init?(json: JSONType) { + self.json = json + } +} diff --git a/RAD/Model/Entities/MetadataGroup.swift b/RAD/Model/Entities/MetadataGroup.swift new file mode 100644 index 0000000..203b115 --- /dev/null +++ b/RAD/Model/Entities/MetadataGroup.swift @@ -0,0 +1,23 @@ +// +// MetadataGroup.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct MetadataGroup { + let metadata: MetadataRelation + let dates: [TimezonedDate] +} diff --git a/RAD/Model/Entities/NetworkResult.swift b/RAD/Model/Entities/NetworkResult.swift new file mode 100644 index 0000000..27f3ddd --- /dev/null +++ b/RAD/Model/Entities/NetworkResult.swift @@ -0,0 +1,23 @@ +// +// NetworkResult.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct NetworkResult { + let status: HttpStatusCode + let batch: Batch +} diff --git a/RAD/Model/Entities/PlayerObserver.swift b/RAD/Model/Entities/PlayerObserver.swift new file mode 100644 index 0000000..39dcac1 --- /dev/null +++ b/RAD/Model/Entities/PlayerObserver.swift @@ -0,0 +1,73 @@ +// +// PlayerObserver.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import AVFoundation +import CoreData + +protocol PlayerObservationDelegate: AnyObject { + /// Delegate function which is called upon creation of ranges. + /// + /// - Parameter ids: The NSMananagedObjectID objects which were saved in + /// database. + func playerDidCreateRanges(with ids: [NSManagedObjectID]) +} + +class PlayerObserver: ItemSessionOperationDelegate { + typealias ItemChangeCompletionClosure = () -> Void + weak var delegate: PlayerObservationDelegate? + + private var currentItemObservation: Any? + private let player: AVPlayer + private let configuration: Configuration + + private var itemChangedCompletion: ItemChangeCompletionClosure? + + init(player: AVPlayer, configuration: Configuration) { + self.player = player + self.configuration = configuration + + currentItemObservation = player.observe( + \.currentItem, + changeHandler: { [weak self] (_, _) in + self?.currentItemDidChange() + }) + } + + // MARK: Private observers + + private func currentItemDidChange() { + guard let item = player.currentItem else { + return + } + + let operation = ItemSessionOperation( + asset: item.asset, player: player, configuration: configuration) + operation.delegate = self + OperationQueue.playerSessions.addOperation(operation) + } + + // MARK: ItemSessionOperationDelegate + + func itemSessionOperationSaveCompletionOperation( + _ itemSessionOperation: ItemSessionOperation + ) -> InputOperation<[NSManagedObjectID]> { + return ClosureInputOperation<[NSManagedObjectID]>( + closure: { objectIds in + self.delegate?.playerDidCreateRanges(with: objectIds) + }) + } +} diff --git a/RAD/Model/Entities/RADPayload.swift b/RAD/Model/Entities/RADPayload.swift new file mode 100644 index 0000000..be26d86 --- /dev/null +++ b/RAD/Model/Entities/RADPayload.swift @@ -0,0 +1,25 @@ +// +// RADPayload.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct RADPayload { + let servers: [Server] + let radMetadata: RadMetadata + let events: [Event] + let sessionId: String +} diff --git a/RAD/Model/Entities/Scheduling.swift b/RAD/Model/Entities/Scheduling.swift new file mode 100644 index 0000000..eac77fa --- /dev/null +++ b/RAD/Model/Entities/Scheduling.swift @@ -0,0 +1,34 @@ +// +// Scheduling.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct Scheduling { + static var last: Date { + get { + let dateString = UserDefaults.standard.string( + forKey: UserDefaultsKeys.lastSchedule) ?? "" + let defaultsValue = ISO8601DateFormatter().date(from: dateString) + return defaultsValue ?? Date.distantPast + } + set { + let dateString = ISO8601DateFormatter().string(from: newValue) + UserDefaults.standard.set(dateString, + forKey: UserDefaultsKeys.lastSchedule) + } + } +} diff --git a/RAD/Model/Entities/TimeComponents.swift b/RAD/Model/Entities/TimeComponents.swift new file mode 100644 index 0000000..f38f8cd --- /dev/null +++ b/RAD/Model/Entities/TimeComponents.swift @@ -0,0 +1,69 @@ +// +// TimeComponents.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct TimeComponents { + let hours: Measurement + let minutes: Measurement + let seconds: Measurement + + var timeInterval: TimeInterval { + return total.converted(to: .seconds).value + } + + var total: Measurement { + return hours + minutes + seconds + } + + init(hours: Double = 0, minutes: Double = 0, seconds: Double = 0) { + self.hours = Measurement(value: hours, unit: .hours) + self.minutes = Measurement(value: minutes, unit: .minutes) + self.seconds = Measurement(value: seconds, unit: .seconds) + } + + init?(timeInterval: TimeInterval) { + guard !timeInterval.isNaN else { return nil } + + let time = Measurement(value: timeInterval, unit: .seconds) + let computedHours = time.converted(to: .hours) + let hours = roundingMeasurement(computedHours) + let computedMinutes = (computedHours - hours).converted(to: .minutes) + let minutes = roundingMeasurement(computedMinutes) + let computedMilliseconds = (computedMinutes - minutes).converted( + to: .milliseconds) + let seconds = roundingMeasurement(computedMilliseconds).converted( + to: .seconds) + + self.hours = hours + self.minutes = minutes + self.seconds = seconds + } + + init?(string: String, componentsSeparator: String) { + let components = string.components(separatedBy: componentsSeparator) + guard components.count >= 3 else { return nil } + + guard let hours = Double.from(string: components[0]) else { return nil } + guard let minutes = Double.from(string: components[1]) else { return nil } + guard let seconds = Double.from(string: components[2]) else { return nil } + + self.hours = Measurement(value: hours, unit: .hours) + self.minutes = Measurement(value: minutes, unit: .minutes) + self.seconds = Measurement(value: seconds, unit: .seconds) + } +} diff --git a/RAD/Model/Entities/TimeRange.swift b/RAD/Model/Entities/TimeRange.swift new file mode 100644 index 0000000..9c29104 --- /dev/null +++ b/RAD/Model/Entities/TimeRange.swift @@ -0,0 +1,28 @@ +// +// TimeRange.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct TimeRange { + let start: TimeRangeBound + let end: TimeRangeBound + + init(start: TimeRangeBound, end: TimeRangeBound) { + self.start = start + self.end = end + } +} diff --git a/RAD/Model/Entities/TimeRangeBound.swift b/RAD/Model/Entities/TimeRangeBound.swift new file mode 100644 index 0000000..47752c3 --- /dev/null +++ b/RAD/Model/Entities/TimeRangeBound.swift @@ -0,0 +1,40 @@ +// +// TimeRangeBound.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreMedia + +struct TimeRangeBound: CustomDebugStringConvertible { + let intervalSince1970: Double? + let timezoneOffset: Int? + let playerTime: CMTime + + var debugDescription: String { + var description = "Date: " + if let intervalSince1970 = self.intervalSince1970 { + description += "\(Date(timeIntervalSince1970: intervalSince1970))\n" + } + if let timezoneOffset = self.timezoneOffset { + description += "TimezoneOffset: \(timezoneOffset)" + } + if let time = CMTimeFormatter().stringFromTime(playerTime) { + description += "\nPlayer time: \(time)" + } + + return description + } +} diff --git a/RAD/Model/Entities/TimeRangeBoundBuilder.swift b/RAD/Model/Entities/TimeRangeBoundBuilder.swift new file mode 100644 index 0000000..05da421 --- /dev/null +++ b/RAD/Model/Entities/TimeRangeBoundBuilder.swift @@ -0,0 +1,40 @@ +// +// TimeRangeBoundBuilder.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreMedia + +protocol TimeRangeBoundBuilder { +} + +extension TimeRangeBoundBuilder { + func createTimeRangeBound( + with time: CMTime?, addDateTimeInformation: Bool = true + ) -> TimeRangeBound? { + guard let time = time else { return nil } + + let intervalSince1970 = addDateTimeInformation ? + Date.now.timeIntervalSince1970 : nil + let timezoneOffset = addDateTimeInformation ? + TimeZone.current.secondsFromGMT() : nil + + return TimeRangeBound( + intervalSince1970: intervalSince1970, + timezoneOffset: timezoneOffset, + playerTime: time) + } +} diff --git a/RAD/Model/Entities/TimeRangeController.swift b/RAD/Model/Entities/TimeRangeController.swift new file mode 100644 index 0000000..04647c7 --- /dev/null +++ b/RAD/Model/Entities/TimeRangeController.swift @@ -0,0 +1,259 @@ +// +// TimeRangeController.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import AVFoundation + +protocol TimeRangeControllerDelegate: AnyObject { + func timeRangeController( + _ timeRangeController: TimeRangeController, + didCreateTimeRange timeRange: TimeRange, + synced: Bool) + func timeRangeControllerDidFinishCreatingRanges( + _ timeRangeController: TimeRangeController, + synced: Bool) +} + +/// Captures ranges of playback. +class TimeRangeController: TimeRangeBoundBuilder { + private typealias ConvertTimeCompletion = (RangeBound) -> Void + + weak var delegate: TimeRangeControllerDelegate? + + private let player: AVPlayer + private var rangeStart: CMTime? { + get { + return startBound?.playerTime + } + set { + startBound = createTimeRangeBound(with: newValue) + } + } + private var rangeEnd: CMTime? { + get { + return endBound?.playerTime + } + set { + endBound = createTimeRangeBound( + with: newValue, addDateTimeInformation: false) + } + } + + private var startBound: TimeRangeBound? + private var endBound: TimeRangeBound? + + private var ranges: [TimeRange] = [] + private var timeJumpedBound: TimeRangeBound? + + private var timer: Timer! + private let interval: Double + + private var periodicTimeObservation: Any? + private var timeControlStatusObservation: Any? + private var itemChangedObservation: Any? + private var itemDidPlayToEndObservation: Any? + private var timeJumpedObservation: Any? + private var applicationWillTerminateObservation: Any? + + private var previousTime: CMTime? + + /// Creates an instance of time range controller. + /// It is required for AVPlayer instance to have an item set before + /// calling the constructor. + /// + /// - Parameter player: The player to observe. + init(player: AVPlayer) { + self.player = player + let intervalInMilliseconds = + Measurement(value: 5, unit: .milliseconds) + self.interval = intervalInMilliseconds.converted(to: .seconds).value + + addObservers() + if let item = player.currentItem { + registerObservers(for: item) + } + } + + // MARK: Private oservers + + private func registerObservers(for item: AVPlayerItem) { + itemDidPlayToEndObservation = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: item, + queue: OperationQueue.player, + using: { [weak self] _ in + guard let strongSelf = self else { return } + strongSelf.saveRange() + strongSelf.mergeTimeRanges() + }) + timeJumpedObservation = NotificationCenter.default.addObserver( + forName: .AVPlayerItemTimeJumped, + object: item, + queue: OperationQueue.timeRange, + using: { [weak self] _ in + guard let strongSelf = self else { return } + + let currentTime = strongSelf.player.currentTime() + if currentTime.seconds >= 1.0 { + strongSelf.timeJumpedBound = + strongSelf.createTimeRangeBound(with: currentTime) + } + }) + } + + // MARK: Private functionality + + private func addObservers() { + itemChangedObservation = player.observe( + \.currentItem, + options: [.new], + changeHandler: { [weak self] (_, _) in + guard let strongSelf = self else { return } + strongSelf.saveRange() + strongSelf.finishRecording() + }) + applicationWillTerminateObservation = + NotificationCenter.default.addObserver( + forName: UIApplication.willTerminateNotification, + object: nil, + queue: OperationQueue.player, + using: { [weak self] _ in + guard let strongSelf = self else { return } + strongSelf.saveRange(async: false) + strongSelf.finishRecording(async: false) + }) + timeControlStatusObservation = player.observe( + \.timeControlStatus, + changeHandler: { [weak self] (player, _) in + guard let strongSelf = self else { return } + + switch player.timeControlStatus { + case .paused: + strongSelf.deleteTimer() + strongSelf.saveRange() + strongSelf.mergeTimeRanges() + case .waitingToPlayAtSpecifiedRate: + let playerTime = player.currentTime() + let time = playerTime.seconds < 0 ? CMTime.zero : playerTime + strongSelf.rangeStart = time + strongSelf.previousTime = time + strongSelf.createTimer() + default: + break + } + }) + } + + private func save(time: CMTime) { + guard time.isValid else { return } + guard time.seconds >= 0 else { return } + + let previousTime = self.previousTime + self.previousTime = time + + guard let previous = previousTime else { + self.rangeStart = time + return + } + + guard self.player.timeControlStatus == .playing else { return } + + guard previous != time else { + return + } + + let diff = time.seconds - previous.seconds + if abs(diff) <= self.interval * 2 { + self.rangeEnd = previous + } else { + saveRange() + self.rangeStart = time + } + + if timeJumpedBound != nil { + mergeTimeRanges() + } + } + + private func saveRange(async: Bool = true) { + guard let start = startBound else { return } + guard let end = endBound else { return } + guard start.playerTime <= end.playerTime else { return } + + DispatchQueue.timeRange.execute(block: { + let range = TimeRange(start: start, end: end) + + self.ranges.append(range) + }, async: async) + + startBound = nil + endBound = nil + } + + private func mergeTimeRanges(async: Bool = true) { + DispatchQueue.timeRange.execute(block: { + guard let startRange = self.ranges.first else { return } + + var lastRangeBound: TimeRangeBound? + if let timeJumpedBound = self.timeJumpedBound, + var index = self.ranges.index(where: { + return $0.end.playerTime > timeJumpedBound.playerTime + }) { + index = index < 1 ? 1 : index + let rangeBeforeJump = self.ranges[index - 1] + lastRangeBound = rangeBeforeJump.end + self.ranges.removeFirst(index) + } else { + lastRangeBound = self.ranges.last?.end + self.ranges.removeAll() + } + + guard let endRangeBound = lastRangeBound else { return } + + if startRange.start.playerTime != endRangeBound.playerTime { + let range = TimeRange( + start: startRange.start, end: endRangeBound) + + self.delegate?.timeRangeController( + self, didCreateTimeRange: range, synced: !async) + } + self.timeJumpedBound = nil + }, async: async) + } + + private func finishRecording(async: Bool = true) { + mergeTimeRanges(async: async) + DispatchQueue.timeRange.execute(block: { + let delegate = self.delegate + delegate?.timeRangeControllerDidFinishCreatingRanges( + self, synced: !async) + }, async: async) + } + + private func createTimer() { + timer = Timer.scheduledTimer( + interval: self.interval, + queue: DispatchQueue.player, + closure: { [weak self] in + guard let strongSelf = self else { return } + strongSelf.save(time: strongSelf.player.currentTime()) + }) + } + + private func deleteTimer() { + timer = nil + } +} diff --git a/RAD/Model/Entities/Timer.swift b/RAD/Model/Entities/Timer.swift new file mode 100644 index 0000000..4b82b48 --- /dev/null +++ b/RAD/Model/Entities/Timer.swift @@ -0,0 +1,56 @@ +// +// Timer.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct Timer { + typealias Closure = () -> Void + + private var internalTimer: DispatchSourceTimer + + init( + interval: TimeInterval, + queue: DispatchQueue? = nil, + closure: @escaping Closure + ) { + self.internalTimer = DispatchSource.makeTimerSource( + flags: [.strict], queue: queue) + + self.internalTimer.schedule( + deadline: DispatchTime.now() + interval, + repeating: interval) + self.internalTimer.setEventHandler(handler: closure) + } + + static func scheduledTimer( + interval: TimeInterval, + queue: DispatchQueue? = nil, + closure: @escaping Closure + ) -> Timer { + let timer = Timer(interval: interval, queue: queue, closure: closure) + timer.start() + return timer + } + + func start() { + internalTimer.resume() + } + + func invalidate() { + internalTimer.cancel() + } +} diff --git a/RAD/Model/Entities/UserDefaultsKeys.swift b/RAD/Model/Entities/UserDefaultsKeys.swift new file mode 100644 index 0000000..b612c86 --- /dev/null +++ b/RAD/Model/Entities/UserDefaultsKeys.swift @@ -0,0 +1,23 @@ +// +// UserDefaultsKeys.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct UserDefaultsKeys { + static let networkScheduler = "npr.rad.networkScheduler" + static let lastSchedule = "npr.rad.lastSchedule" +} diff --git a/RAD/Model/Entities/WeakReference.swift b/RAD/Model/Entities/WeakReference.swift new file mode 100644 index 0000000..5fc8f13 --- /dev/null +++ b/RAD/Model/Entities/WeakReference.swift @@ -0,0 +1,34 @@ +// +// WeakReference.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct WeakReference: Equatable { + var value: Type? { + return storage as? Type + } + + private weak var storage: AnyObject? + + init(value: Type) { + self.storage = value as AnyObject + } + + static func == (_ lhs: WeakReference, _ rhs: WeakReference) -> Bool { + return lhs.storage === rhs.storage + } +} diff --git a/RAD/Model/Entities/WeakReferenceContainer.swift b/RAD/Model/Entities/WeakReferenceContainer.swift new file mode 100644 index 0000000..b3f47d7 --- /dev/null +++ b/RAD/Model/Entities/WeakReferenceContainer.swift @@ -0,0 +1,44 @@ +// +// WeakReferenceContainer.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct WeakReferenceContainer { + private var container: [WeakReference] = [] + + init() {} + + mutating func append(_ value: Type) { + container.append(WeakReference(value: value)) + } + + mutating func remove(_ value: Type) { + let weakReference = WeakReference(value: value) + guard let index = container.index(where: { reference -> Bool in + reference == weakReference + }) else { return } + container.remove(at: index) + } + + mutating func forEach(_ closure: (Type?) -> Void) { + let availableObjects = container.filter({ $0.value != nil }) + container = availableObjects + container.forEach({ + closure($0.value) + }) + } +} diff --git a/RAD/Model/ListeningObserver.swift b/RAD/Model/ListeningObserver.swift new file mode 100644 index 0000000..94679e5 --- /dev/null +++ b/RAD/Model/ListeningObserver.swift @@ -0,0 +1,26 @@ +// +// ListeningObserver.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// A protocol which may be implemented to observe the listens changes. +public protocol ListeningObserver: AnyObject { + /// Callback which is executed upon creating a listening range. + /// + /// - Parameter range: The created Range object. + func didGenerateListeningRanges(_ ranges: [Object]) +} diff --git a/RAD/Model/Network/HttpStatusCode.swift b/RAD/Model/Network/HttpStatusCode.swift new file mode 100644 index 0000000..518abfb --- /dev/null +++ b/RAD/Model/Network/HttpStatusCode.swift @@ -0,0 +1,32 @@ +// +// HttpStatusCode.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +struct HttpStatusCode: Hashable { + enum Class { + case informational, success, redirection, client, server, unknown + } + + let code: Int + let `class`: Class + let description: String + + static func == (_ lhs: HttpStatusCode, _ rhs: HttpStatusCode) -> Bool { + return lhs.code == rhs.code + } +} diff --git a/RAD/Model/Network/HttpStatusCodeList.swift b/RAD/Model/Network/HttpStatusCodeList.swift new file mode 100644 index 0000000..496354b --- /dev/null +++ b/RAD/Model/Network/HttpStatusCodeList.swift @@ -0,0 +1,98 @@ +// +// HttpStatusCodeList.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +// swiftlint:disable line_length identifier_name +extension HttpStatusCode { + // MARK: Informational + static let `continue` = HttpStatusCode(code: 100, class: .informational, description: "The server has received the request headers and the client should proceed to send the request body (in the case of a request for which a body needs to be sent; for example, a POST request). Sending a large request body to a server after a request has been rejected for inappropriate headers would be inefficient. To have a server check the request's headers, a client must send Expect: 100-continue as a header in its initial request and receive a 100 Continue status code in response before sending the body. If the client receives an error code such as 403 (Forbidden) or 405 (Method Not Allowed) then it shouldn't send the request's body. The response 417 Expectation Failed indicates that the request should be repeated without the Expect header as it indicates that the server doesn't support expectations (this is the case, for example, of HTTP/1.0 servers).") + static let switchingProtocols = HttpStatusCode(code: 101, class: .informational, description: "The requester has asked the server to switch protocols and the server has agreed to do so.") + static let processing = HttpStatusCode(code: 102, class: .informational, description: "A WebDAV request may contain many sub-requests involving file operations, requiring a long time to complete the request. This code indicates that the server has received and is processing the request, but no response is available yet. This prevents the client from timing out and assuming the request was lost.") + static let earlyHints = HttpStatusCode(code: 103, class: .informational, description: "Used to return some response headers before final HTTP message.") + + // MARK: Success + static let ok = HttpStatusCode(code: 200, class: .success, description: "Standard response for successful HTTP requests. The actual response will depend on the request method used. In a GET request, the response will contain an entity corresponding to the requested resource. In a POST request, the response will contain an entity describing or containing the result of the action.") + static let created = HttpStatusCode(code: 201, class: .success, description: "The request has been fulfilled, resulting in the creation of a new resource.") + static let accepted = HttpStatusCode(code: 202, class: .success, description: "The request has been accepted for processing, but the processing has not been completed. The request might or might not be eventually acted upon, and may be disallowed when processing occurs.") + static let nonAuthoritativeInformation = HttpStatusCode(code: 203, class: .success, description: "The server is a transforming proxy (e.g. a Web accelerator) that received a 200 OK from its origin, but is returning a modified version of the origin's response.") + static let noContent = HttpStatusCode(code: 204, class: .success, description: "The server successfully processed the request and is not returning any content.") + static let resetContent = HttpStatusCode(code: 205, class: .success, description: "The server successfully processed the request, but is not returning any content. Unlike a 204 response, this response requires that the requester reset the document view.") + static let partialContent = HttpStatusCode(code: 206, class: .success, description: "The server is delivering only part of the resource (byte serving) due to a range header sent by the client. The range header is used by HTTP clients to enable resuming of interrupted downloads, or split a download into multiple simultaneous streams.") + static let multiStatus = HttpStatusCode(code: 207, class: .success, description: "The message body that follows is by default an XML message and can contain a number of separate response codes, depending on how many sub-requests were made.") + static let alreadyReported = HttpStatusCode(code: 208, class: .success, description: "The members of a DAV binding have already been enumerated in a preceding part of the (multistatus) response, and are not being included again.") + static let imUsed = HttpStatusCode(code: 226, class: .success, description: "The server has fulfilled a request for the resource, and the response is a representation of the result of one or more instance-manipulations applied to the current instance.") + + // MARK: Redirection + static let multipleChoices = HttpStatusCode(code: 300, class: .redirection, description: "Indicates multiple options for the resource from which the client may choose (via agent-driven content negotiation). For example, this code could be used to present multiple video format options, to list files with different filename extensions, or to suggest word-sense disambiguation.") + static let movedPermanently = HttpStatusCode(code: 301, class: .redirection, description: "This and all future requests should be directed to the given URI.") + static let found = HttpStatusCode(code: 302, class: .redirection, description: "Tells the client to look at (browse to) another url. 302 has been superseded by 303 and 307. This is an example of industry practice contradicting the standard. The HTTP/1.0 specification (RFC 1945) required the client to perform a temporary redirect (the original describing phrase was 'Moved Temporarily'), but popular browsers implemented 302 with the functionality of a 303 See Other. Therefore, HTTP/1.1 added status codes 303 and 307 to distinguish between the two behaviours. However, some Web applications and frameworks use the 302 status code as if it were the 303.") + static let seeOther = HttpStatusCode(code: 303, class: .redirection, description: "The response to the request can be found under another URI using the GET method. When received in response to a POST (or PUT/DELETE), the client should presume that the server has received the data and should issue a new GET request to the given URI.") + static let notModified = HttpStatusCode(code: 304, class: .redirection, description: "Indicates that the resource has not been modified since the version specified by the request headers If-Modified-Since or If-None-Match. In such case, there is no need to retransmit the resource since the client still has a previously-downloaded copy.") + static let useProxy = HttpStatusCode(code: 305, class: .redirection, description: "The requested resource is available only through a proxy, the address for which is provided in the response. Many HTTP clients (such as Mozilla and Internet Explorer) do not correctly handle responses with this status code, primarily for security reasons.") + static let switchProxy = HttpStatusCode(code: 306, class: .redirection, description: "No longer used. Originally meant 'Subsequent requests should use the specified proxy'.") + static let temporaryRedirect = HttpStatusCode(code: 307, class: .redirection, description: "In this case, the request should be repeated with another URI; however, future requests should still use the original URI. In contrast to how 302 was historically implemented, the request method is not allowed to be changed when reissuing the original request. For example, a POST request should be repeated using another POST request.") + static let permanentRedirect = HttpStatusCode(code: 308, class: .redirection, description: "The request and all future requests should be repeated using another URI. 307 and 308 parallel the behaviors of 302 and 301, but do not allow the HTTP method to change. So, for example, submitting a form to a permanently redirected resource may continue smoothly.") + + // MARK: Client + static let badRequest = HttpStatusCode(code: 400, class: .client, description: "The server cannot or will not process the request due to an apparent client error (e.g., malformed request syntax, size too large, invalid request message framing, or deceptive request routing).") + static let unauthorized = HttpStatusCode(code: 401, class: .client, description: "Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet been provided. The response must include a WWW-Authenticate header field containing a challenge applicable to the requested resource. See Basic access authentication and Digest access authentication. 401 semantically means 'unauthenticated', i.e. the user does not have the necessary credentials. Note: Some sites issue HTTP 401 when an IP address is banned from the website (usually the website domain) and that specific address is refused permission to access a website.") + static let paymentRequired = HttpStatusCode(code: 402, class: .client, description: "Reserved for future use. The original intention was that this code might be used as part of some form of digital cash or micropayment scheme, as proposed for example by GNU Taler, but that has not yet happened, and this code is not usually used. Google Developers API uses this status if a particular developer has exceeded the daily limit on requests.") + static let forbidden = HttpStatusCode(code: 403, class: .client, description: "The request was valid, but the server is refusing action. The user might not have the necessary permissions for a resource, or may need an account of some sort.") + static let notFound = HttpStatusCode(code: 404, class: .client, description: "The requested resource could not be found but may be available in the future. Subsequent requests by the client are permissible.") + static let methodNotAllowed = HttpStatusCode(code: 405, class: .client, description: "A request method is not supported for the requested resource; for example, a GET request on a form that requires data to be presented via POST, or a PUT request on a read-only resource.") + static let notAcceptable = HttpStatusCode(code: 406, class: .client, description: "The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request.") + static let proxyAuthenticationRequired = HttpStatusCode(code: 407, class: .client, description: "The client must first authenticate itself with the proxy.") + static let requestTimeout = HttpStatusCode(code: 408, class: .client, description: "The server timed out waiting for the request. According to HTTP specifications: 'The client did not produce a request within the time that the server was prepared to wait. The client MAY repeat the request without modifications at any later time.'") + static let conflict = HttpStatusCode(code: 409, class: .client, description: "Indicates that the request could not be processed because of conflict in the request, such as an edit conflict between multiple simultaneous updates.") + static let gone = HttpStatusCode(code: 410, class: .client, description: "Indicates that the resource requested is no longer available and will not be available again. This should be used when a resource has been intentionally removed and the resource should be purged. Upon receiving a 410 status code, the client should not request the resource in the future. Clients such as search engines should remove the resource from their indices. Most use cases do not require clients and search engines to purge the resource, and a '404 Not Found' may be used instead.") + static let lengthRequired = HttpStatusCode(code: 411, class: .client, description: "The request did not specify the length of its content, which is required by the requested resource.") + static let preconditionFailed = HttpStatusCode(code: 412, class: .client, description: "The server does not meet one of the preconditions that the requester put on the request.") + static let payloadTooLarge = HttpStatusCode(code: 413, class: .client, description: "The request is larger than the server is willing or able to process. Previously called 'Request Entity Too Large'.") + static let uriTooLong = HttpStatusCode(code: 414, class: .client, description: "The URI provided was too long for the server to process. Often the result of too much data being encoded as a query-string of a GET request, in which case it should be converted to a POST request. Called 'Request-URI Too Long' previously.") + static let unsupportedMediaType = HttpStatusCode(code: 415, class: .client, description: "The request entity has a media type which the server or resource does not support. For example, the client uploads an image as image/svg+xml, but the server requires that images use a different format.") + static let rangeNotSatifiable = HttpStatusCode(code: 416, class: .client, description: "The client has asked for a portion of the file (byte serving), but the server cannot supply that portion. For example, if the client asked for a part of the file that lies beyond the end of the file. Called 'Requested Range Not Satisfiable' previously.") + static let expectationFailed = HttpStatusCode(code: 417, class: .client, description: "The server cannot meet the requirements of the Expect request-header field.") + static let imATeapot = HttpStatusCode(code: 418, class: .client, description: "This code was defined in 1998 as one of the traditional IETF April Fools' jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol, and is not expected to be implemented by actual HTTP servers. The RFC specifies this code should be returned by teapots requested to brew coffee. This HTTP status is used as an Easter egg in some websites, including Google.com.") + static let misdirectedRequest = HttpStatusCode(code: 421, class: .client, description: "The request was directed at a server that is not able to produce a response (for example because of connection reuse).") + static let unprocessableEntity = HttpStatusCode(code: 422, class: .client, description: "The request was well-formed but was unable to be followed due to semantic errors.") + static let locked = HttpStatusCode(code: 423, class: .client, description: "The resource that is being accessed is locked.") + static let failedDependency = HttpStatusCode(code: 424, class: .client, description: "The request failed because it depended on another request and that request failed (e.g., a PROPPATCH).") + static let upgradeRequired = HttpStatusCode(code: 426, class: .client, description: "The client should switch to a different protocol such as TLS/1.0, given in the Upgrade header field.") + static let preconditionRequired = HttpStatusCode(code: 428, class: .client, description: "The origin server requires the request to be conditional. Intended to prevent the 'lost update' problem, where a client GETs a resource's state, modifies it, and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict.") + static let tooManyRequests = HttpStatusCode(code: 429, class: .client, description: "The user has sent too many requests in a given amount of time. Intended for use with rate-limiting schemes.") + static let requestHeaderFieldsTooLarge = HttpStatusCode(code: 431, class: .client, description: "The server is unwilling to process the request because either an individual header field, or all the header fields collectively, are too large.") + static let unavailableForLegalReasons = HttpStatusCode(code: 451, class: .client, description: "A server operator has received a legal demand to deny access to a resource or to a set of resources that includes the requested resource.[57] The code 451 was chosen as a reference to the novel Fahrenheit 451 (see the Acknowledgements in the RFC).") + + // MARK: Server + static let internalServerError = HttpStatusCode(code: 500, class: .server, description: "A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.") + static let notImplemented = HttpStatusCode(code: 501, class: .server, description: "The server either does not recognize the request method, or it lacks the ability to fulfil the request. Usually this implies future availability (e.g., a new feature of a web-service API).") + static let badGateway = HttpStatusCode(code: 502, class: .server, description: "The server was acting as a gateway or proxy and received an invalid response from the upstream server.") + static let serviceUnavailable = HttpStatusCode(code: 503, class: .server, description: "The server is currently unavailable (because it is overloaded or down for maintenance). Generally, this is a temporary state.") + static let gatewayTimeout = HttpStatusCode(code: 504, class: .server, description: "The server was acting as a gateway or proxy and did not receive a timely response from the upstream server.") + static let httpVersionNotSupported = HttpStatusCode(code: 505, class: .server, description: "The server does not support the HTTP protocol version used in the request.") + static let variantAlsoNegociates = HttpStatusCode(code: 506, class: .server, description: "Transparent content negotiation for the request results in a circular reference.") + static let insufficientStorage = HttpStatusCode(code: 507, class: .server, description: "The server is unable to store the representation needed to complete the request.") + static let loopDetected = HttpStatusCode(code: 508, class: .server, description: "The server detected an infinite loop while processing the request (sent in lieu of 208 Already Reported).") + static let notExtended = HttpStatusCode(code: 510, class: .server, description: "Further extensions to the request are required for the server to fulfil it.") + static let networkAuthenticationRequired = HttpStatusCode(code: 511, class: .server, description: "The client needs to authenticate to gain network access. Intended for use by intercepting proxies used to control access to the network (e.g., 'captive portals' used to require agreement to Terms of Service before granting full Internet access via a Wi-Fi hotspot).") + + // MARK: Others + static let unknown = HttpStatusCode(code: Int.min, class: .unknown, description: "This status code is not in the list of HTPP status code maintained by Internet Assigned Numbers Authority (IANA).") +} + +// swiftlint:enable line_length identifier_name diff --git a/RAD/Model/Network/HttpStatusCodeMapping.swift b/RAD/Model/Network/HttpStatusCodeMapping.swift new file mode 100644 index 0000000..3c0ffd3 --- /dev/null +++ b/RAD/Model/Network/HttpStatusCodeMapping.swift @@ -0,0 +1,102 @@ +// +// HttpStatusCodeMapping.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension HttpStatusCode { + static func with(_ rawCode: Int) -> HttpStatusCode { + return HttpStatusCode.map[rawCode] ?? HttpStatusCode.unknown + } + + private static let map: [Int: HttpStatusCode] = { + var map: [Int: HttpStatusCode] = [:] + + // MARK: Informational + map[100] = HttpStatusCode.continue + map[101] = HttpStatusCode.switchingProtocols + map[102] = HttpStatusCode.processing + map[103] = HttpStatusCode.earlyHints + + // MARK: Success + map[200] = HttpStatusCode.ok + map[201] = HttpStatusCode.created + map[202] = HttpStatusCode.accepted + map[203] = HttpStatusCode.nonAuthoritativeInformation + map[204] = HttpStatusCode.noContent + map[205] = HttpStatusCode.resetContent + map[206] = HttpStatusCode.partialContent + map[207] = HttpStatusCode.multiStatus + map[208] = HttpStatusCode.alreadyReported + map[226] = HttpStatusCode.imUsed + + // MARK: Redirection + map[300] = HttpStatusCode.multipleChoices + map[301] = HttpStatusCode.movedPermanently + map[302] = HttpStatusCode.found + map[303] = HttpStatusCode.seeOther + map[304] = HttpStatusCode.notModified + map[305] = HttpStatusCode.useProxy + map[306] = HttpStatusCode.switchProxy + map[307] = HttpStatusCode.temporaryRedirect + map[308] = HttpStatusCode.permanentRedirect + + // MARK: Client + map[400] = HttpStatusCode.badRequest + map[401] = HttpStatusCode.unauthorized + map[402] = HttpStatusCode.paymentRequired + map[403] = HttpStatusCode.forbidden + map[404] = HttpStatusCode.notFound + map[405] = HttpStatusCode.methodNotAllowed + map[406] = HttpStatusCode.notAcceptable + map[407] = HttpStatusCode.proxyAuthenticationRequired + map[408] = HttpStatusCode.requestTimeout + map[409] = HttpStatusCode.conflict + map[410] = HttpStatusCode.gone + map[411] = HttpStatusCode.lengthRequired + map[412] = HttpStatusCode.preconditionFailed + map[413] = HttpStatusCode.payloadTooLarge + map[414] = HttpStatusCode.uriTooLong + map[415] = HttpStatusCode.unsupportedMediaType + map[416] = HttpStatusCode.rangeNotSatifiable + map[417] = HttpStatusCode.expectationFailed + map[418] = HttpStatusCode.imATeapot + map[421] = HttpStatusCode.misdirectedRequest + map[422] = HttpStatusCode.unprocessableEntity + map[423] = HttpStatusCode.locked + map[424] = HttpStatusCode.failedDependency + map[426] = HttpStatusCode.upgradeRequired + map[428] = HttpStatusCode.preconditionRequired + map[429] = HttpStatusCode.tooManyRequests + map[431] = HttpStatusCode.requestHeaderFieldsTooLarge + map[451] = HttpStatusCode.unavailableForLegalReasons + + // MARK: Server + map[500] = HttpStatusCode.internalServerError + map[501] = HttpStatusCode.notImplemented + map[502] = HttpStatusCode.badGateway + map[503] = HttpStatusCode.serviceUnavailable + map[504] = HttpStatusCode.gatewayTimeout + map[505] = HttpStatusCode.httpVersionNotSupported + map[506] = HttpStatusCode.variantAlsoNegociates + map[507] = HttpStatusCode.insufficientStorage + map[508] = HttpStatusCode.loopDetected + map[510] = HttpStatusCode.notExtended + map[511] = HttpStatusCode.networkAuthenticationRequired + + return map + }() +} diff --git a/RAD/Model/Network/NetworkScheduler.swift b/RAD/Model/Network/NetworkScheduler.swift new file mode 100644 index 0000000..d1f0724 --- /dev/null +++ b/RAD/Model/Network/NetworkScheduler.swift @@ -0,0 +1,91 @@ +// +// NetworkScheduler.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class NetworkScheduler { + let configuration: Configuration + private var appDidEnterBackgroundObservation: Any? + private var appWillEnterForegroundObservation: Any? + + private var lastUpdate: Date { + get { + let dateString = UserDefaults.standard.string( + forKey: UserDefaultsKeys.networkScheduler + ) ?? "" + let defaultsValue = ISO8601DateFormatter().date(from: dateString) + return defaultsValue ?? Date.distantPast + } + set { + let dateString = ISO8601DateFormatter().string(from: newValue) + UserDefaults.standard.set( + dateString, forKey: UserDefaultsKeys.networkScheduler) + } + } + + init(configuration: Configuration) { + self.configuration = configuration + + appDidEnterBackgroundObservation = + NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: OperationQueue.background, + using: { _ in + ScheduleDataSend.cancelScheduledDataSend() + }) + appWillEnterForegroundObservation = + NotificationCenter.default.addObserver( + forName: UIApplication.willEnterForegroundNotification, + object: nil, + queue: OperationQueue.background, + using: { [weak self] _ in + self?.startScheduling() + }) + } + + func startScheduling() { + endScheduling() + ScheduleDataSend.scheduleDataSend(configuration: configuration) + } + + func endScheduling() { + ScheduleDataSend.cancelScheduledDataSend() + } + + func executeDataSent( + with completion: @escaping ScheduleDataSend.DataSendCompletion + ) { + let findNextSchedule = FindNextSchedule(configuration: configuration) + let closureOperation = ClosureInputOperation { + if $0 > 0 { + completion(.noData) + } else { + let dataSendOperation = ScheduleDataSend( + configuration: self.configuration, + repeats: false, + sentCompletion: completion) + OperationQueue.background.addOperation(dataSendOperation) + } + } + findNextSchedule.chainOperation(with: closureOperation) + OperationQueue.background.addOperations( + [findNextSchedule, closureOperation], + waitUntilFinished: false) + } +} diff --git a/RAD/Model/Network/NetworkService.swift b/RAD/Model/Network/NetworkService.swift new file mode 100644 index 0000000..82bfe14 --- /dev/null +++ b/RAD/Model/Network/NetworkService.swift @@ -0,0 +1,59 @@ +// +// NetworkService.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class NetworkService: NSObject, URLSessionTaskDelegate { + typealias Completion = ( + _ response: HTTPURLResponse?, _ error: Error? + ) -> Void + + static let shared = NetworkService() + + private var session: URLSession! + private var tasksMap: [URLSessionTask: Completion] = [:] + + private override init() { + super.init() + session = URLSession( + configuration: URLSessionConfiguration.framework, + delegate: self, + delegateQueue: OperationQueue.background) + } + + func executeRequest(_ request: URLRequest, completion: Completion? = nil) { + let task = session.dataTask(with: request) + if let completion = completion { + tasksMap[task] = completion + } + task.resume() + } + + // MARK: URLSessionDelegate + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + guard let completion = tasksMap[task] else { return } + + let response = task.response as? HTTPURLResponse + tasksMap[task] = nil + completion(response, error) + } +} diff --git a/RAD/Model/Network/RequestBuilder.swift b/RAD/Model/Network/RequestBuilder.swift new file mode 100644 index 0000000..594da0e --- /dev/null +++ b/RAD/Model/Network/RequestBuilder.swift @@ -0,0 +1,50 @@ +// +// RequestBuilder.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class RequestBuilder { + private let batch: Batch + + init(batch: Batch) { + self.batch = batch + } + + func buildRequest(with configuration: Configuration) -> URLRequest? { + guard let url = batch.server.trackingURI else { return nil } + guard let body = buildBody() else { return nil } + var request = URLRequest(url: url) + request.method = .post + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + for (key, value) in configuration.requestHeaderFields { + request.setValue(value, forHTTPHeaderField: key) + } + return request + } + + // MARK: Private functionality + + private func buildBody() -> Data? { + do { + return try JSONSerialization.data(withJSONObject: batch.json, options: []) + } catch { + print("Unable to convert events batch into raw data.") + return nil + } + } +} diff --git a/RAD/Model/Object.swift b/RAD/Model/Object.swift new file mode 100644 index 0000000..9c48717 --- /dev/null +++ b/RAD/Model/Object.swift @@ -0,0 +1,28 @@ +// +// Object.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +public typealias Object = JSONDictionary + +public enum ObjectType: String { + case event = "Event", itemSession = "ItemSession" + case itemSessionId = "ItemSessionID", rad = "Rad" + case radMetadata = "RadMetadata", metadataRelation = "MetadataRelation" + case range = "Range", rangeBound = "RangeBound", server = "Server" + case timezonedDate = "TimezonedDate" +} diff --git a/RAD/Model/Operations/ChainedOperations/AsyncClosureInputOperation.swift b/RAD/Model/Operations/ChainedOperations/AsyncClosureInputOperation.swift new file mode 100644 index 0000000..747ad63 --- /dev/null +++ b/RAD/Model/Operations/ChainedOperations/AsyncClosureInputOperation.swift @@ -0,0 +1,49 @@ +// +// AsyncClosureInputOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// An input operation which facilitates ease of access to a value computed +/// by another operation within a closure. +/// +/// The operation allows the closure to perform asynchronous work without +/// finishing the operation until the completion handle, +/// provided as parameter, is called. +class AsyncClosureInputOperation: InputOperation { + typealias Completion = () -> Void + + typealias Closure = ( + _ input: Input, _ completion: @escaping Completion + ) -> Void + + private let closure: Closure + + init(closure: @escaping Closure) { + self.closure = closure + } + + override func execute() { + guard let input = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + closure(input, { + self.finish() + }) + } +} diff --git a/RAD/Model/Operations/ChainedOperations/ChainOperation.swift b/RAD/Model/Operations/ChainedOperations/ChainOperation.swift new file mode 100644 index 0000000..034c9e3 --- /dev/null +++ b/RAD/Model/Operations/ChainedOperations/ChainOperation.swift @@ -0,0 +1,40 @@ +// +// ChainOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// An operation which converts input into output. +class ChainOperation: InputOperation, +OutputOperationType { + typealias OutputType = Output + + var output: Output? + var chainedOperations = + WeakReferenceContainer>() + + override func cancel() { + cancelChainedOperations() + + super.cancel() + } + + override func finish(with error: Error? = nil) { + willFinish(with: error) + + super.finish(with: error) + } +} diff --git a/RAD/Model/Operations/ChainedOperations/ClosureInputOperation.swift b/RAD/Model/Operations/ChainedOperations/ClosureInputOperation.swift new file mode 100644 index 0000000..11b2a46 --- /dev/null +++ b/RAD/Model/Operations/ChainedOperations/ClosureInputOperation.swift @@ -0,0 +1,43 @@ +// +// ClosureInputOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// An input operation which facilitates ease of access to a value computed +/// by another operation within a closure. +/// +/// The operation finishes after calling the closure. +/// For async support see AsyncClosureInputOperation. +class ClosureInputOperation: InputOperation { + typealias Closure = (_ input: Input) -> Void + + private let closure: Closure + + init(closure: @escaping Closure) { + self.closure = closure + } + + override func execute() { + guard let input = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + closure(input) + finish() + } +} diff --git a/RAD/Model/Operations/ChainedOperations/InputOperation.swift b/RAD/Model/Operations/ChainedOperations/InputOperation.swift new file mode 100644 index 0000000..8d86e58 --- /dev/null +++ b/RAD/Model/Operations/ChainedOperations/InputOperation.swift @@ -0,0 +1,35 @@ +// +// InputOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// Input operation is not ready by default. +/// It becomes ready when input property is set, +/// usually by an OutputOperationType. +class InputOperation: Operation { + var input: Input? { + didSet { + updateReady(input != nil) + } + } + + override init() { + super.init() + + updateReady(false) + } +} diff --git a/RAD/Model/Operations/ChainedOperations/Operation.swift b/RAD/Model/Operations/ChainedOperations/Operation.swift new file mode 100644 index 0000000..2cb3290 --- /dev/null +++ b/RAD/Model/Operations/ChainedOperations/Operation.swift @@ -0,0 +1,109 @@ +// +// Operation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// Basic operation which provides a simple interface to ease the work with +/// Foundation operation. +class Operation: Foundation.Operation { + override var isExecuting: Bool { + return _isExecuting + } + + override var isFinished: Bool { + return _isFinished + } + + override var isReady: Bool { + guard super.isReady else { return false } + return _isReady || isCancelled + } + + override var isAsynchronous: Bool { + return true + } + + /// The error which occured during the execution of operation. + /// If the property is nil when completionBlock is called, + /// then operation executed without errors. + private (set) var finishError: Error? + + static let isExecutingKey = "isExecuting" + static let isFinishedKey = "isFinished" + static let isReadyKey = "isReady" + + private var _isExecuting: Bool = false + private var _isFinished: Bool = false + private var _isReady: Bool = true + + /// Updates *isExecuting* property by conforming to manual KVO compliance. + /// + /// - Parameter isExecuting: The new value of *isExecuting*. + func updateExecution(_ isExecuting: Bool) { + willChangeValue(forKey: Operation.isExecutingKey) + _isExecuting = isExecuting + didChangeValue(forKey: Operation.isExecutingKey) + } + + /// Updates *isFinished* property by conforming to manual KVO compliance. + /// + /// - Parameter isFinished: The new value of *isFinished*. + func updateFinished(_ isFinished: Bool) { + willChangeValue(forKey: Operation.isFinishedKey) + _isFinished = isFinished + didChangeValue(forKey: Operation.isFinishedKey) + } + + /// Updates *isReady* property by conforming to manual KVO compliance. + /// + /// - Parameter isReady: The new value of *isReady*. + func updateReady(_ isReady: Bool) { + willChangeValue(forKey: Operation.isReadyKey) + _isReady = isReady + didChangeValue(forKey: Operation.isReadyKey) + } + + /// Subclasses should override this function and perform its work. + /// + /// Should not call super, but upon completion, + /// it has the responsibility to call *finish(with:)* function. + func execute() { + assertionFailure("Execute function should be overriden.") + finish() + } + + override final func start() { + guard !isCancelled else { + finish() + return + } + + updateExecution(true) + execute() + } + + /// Finish the operation and retains the error on *finishError* property. + /// + /// - Parameter error: The error which occured during the execution of + /// operation. If operation was successful, no parameter needs to be set, + /// since *nil* is the default value. + func finish(with error: Error? = nil) { + finishError = error + updateExecution(false) + updateFinished(true) + } +} diff --git a/RAD/Model/Operations/ChainedOperations/OutputOperation.swift b/RAD/Model/Operations/ChainedOperations/OutputOperation.swift new file mode 100644 index 0000000..a581bf6 --- /dev/null +++ b/RAD/Model/Operations/ChainedOperations/OutputOperation.swift @@ -0,0 +1,40 @@ +// +// OutputOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// Creates an output. It the operation was chained with input operations, +/// it will pass the ouput objects to all chained operations. +class OutputOperation: Operation, OutputOperationType { + typealias OutputType = Output + + var output: Output? + var chainedOperations = + WeakReferenceContainer>() + + override func cancel() { + cancelChainedOperations() + + super.cancel() + } + + override func finish(with error: Error? = nil) { + willFinish(with: error) + + super.finish(with: error) + } +} diff --git a/RAD/Model/Operations/ChainedOperations/OutputOperationType.swift b/RAD/Model/Operations/ChainedOperations/OutputOperationType.swift new file mode 100644 index 0000000..80ce444 --- /dev/null +++ b/RAD/Model/Operations/ChainedOperations/OutputOperationType.swift @@ -0,0 +1,77 @@ +// +// OutputOperationType.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +protocol OutputOperationType: AnyObject { + associatedtype OutputType + + // Properties should not be updated dirrectly because + // the setter function ensures Type's operability. + var output: OutputType? { get set } + var chainedOperations: WeakReferenceContainer> { + get set + } + + /// Updates the output property. The default implementation also calls + /// the chained operations. + /// + /// - Parameter output: The ouput to be set. + func setOutput(_ output: OutputType) + /// Chains the option with self. + /// + /// - Parameter operation: The operation to be chained with. + func chainOperation(with operation: InputOperation) + /// Cancells all chained operations. + func cancelChainedOperations() + /// Finishes self operation with provided error. + /// + /// - Parameter error: The error which occured during processing the output. + func willFinish(with error: Error?) + /// Finishes self with the produces output object. Default implementation + /// cancells the chained operations. + /// + /// - Parameter output: The object which was created. + func finish(with output: OutputType) +} + +extension OutputOperationType where Self: Operation { + func setOutput(_ output: OutputType) { + self.output = output + chainedOperations.forEach({ $0?.input = output }) + } + + func chainOperation(with operation: InputOperation) { + operation.addDependency(self) + chainedOperations.append(operation) + } + + func cancelChainedOperations() { + chainedOperations.forEach({ $0?.cancel() }) + } + + func willFinish(with error: Error?) { + if error != nil { + cancelChainedOperations() + } + } + + func finish(with output: OutputType) { + setOutput(output) + finish() + } +} diff --git a/RAD/Model/Operations/CoreData/ContextFetchOperation.swift b/RAD/Model/Operations/CoreData/ContextFetchOperation.swift new file mode 100644 index 0000000..75dd02f --- /dev/null +++ b/RAD/Model/Operations/CoreData/ContextFetchOperation.swift @@ -0,0 +1,36 @@ +// +// ContextFetchOperation.swift +// RAD +// +// Created by David Livadaru on 18/09/2018. +// Copyright © 2018 National Public Radio. All rights reserved. +// + +import Foundation +import CoreData + +/// An operation which outputs the array of fetched NSManagedObject +/// based on the array of NSManagedObjectID. +class ContextFetchOperation: +ChainOperation<[NSManagedObjectID], [T]> { + private let context: NSManagedObjectContext + + /// - Parameter context: the context where objects are fetched into. + init(context: NSManagedObjectContext) { + self.context = context + } + + override func execute() { + guard let ids = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + context.perform { + let objects = ids.compactMap({ + self.context.object(with: $0) as? T + }) + self.finish(with: objects) + } + } +} diff --git a/RAD/Model/Operations/CoreData/ContextTransferOperation.swift b/RAD/Model/Operations/CoreData/ContextTransferOperation.swift new file mode 100644 index 0000000..e4c8a87 --- /dev/null +++ b/RAD/Model/Operations/CoreData/ContextTransferOperation.swift @@ -0,0 +1,33 @@ +// +// ContextTransferOperation.swift +// RAD +// +// Created by David Livadaru on 17/08/2018. +// Copyright © 2018 National Public Radio. All rights reserved. +// + +import Foundation +import CoreData + +/// An operation which outputs the object ids of an array of NSManagedObjectID. +class ContextTransferOperation: +ChainOperation<[T], [NSManagedObjectID]> { + private let context: NSManagedObjectContext + + /// - Parameter context: The context in which objects are fetched into. + init(context: NSManagedObjectContext) { + self.context = context + } + + override func execute() { + guard let objects = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + context.perform { + let objectIds = objects.map({ $0.objectID }) + self.finish(with: objectIds) + } + } +} diff --git a/RAD/Model/Operations/CoreData/DeleteOperation.swift b/RAD/Model/Operations/CoreData/DeleteOperation.swift new file mode 100644 index 0000000..28db09d --- /dev/null +++ b/RAD/Model/Operations/CoreData/DeleteOperation.swift @@ -0,0 +1,31 @@ +// +// DeleteOperation.swift +// RAD +// +// Created by David Livadaru on 20/08/2018. +// Copyright © 2018 National Public Radio. All rights reserved. +// + +import Foundation +import CoreData + +/// Deletes an array of input NSManagedObjects from context. +class DeleteOperation: InputOperation<[T]> { + let context: NSManagedObjectContext + + init(context: NSManagedObjectContext) { + self.context = context + } + + override func execute() { + guard let objects = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + context.perform { + self.context.deleteObjects(objects) + self.finish() + } + } +} diff --git a/RAD/Model/Operations/CoreData/FetchOperation.swift b/RAD/Model/Operations/CoreData/FetchOperation.swift new file mode 100644 index 0000000..0bfb0c9 --- /dev/null +++ b/RAD/Model/Operations/CoreData/FetchOperation.swift @@ -0,0 +1,59 @@ +// +// FetchOperation.swift +// RAD +// +// Created by David Livadaru on 17/08/2018. +// Copyright © 2018 National Public Radio. All rights reserved. +// + +import Foundation +import CoreData + +/// Fetch operations are ready to be executed by default. +/// Chaining it with Output will wait until NSPredicate is set. +class FetchOperation: +ChainOperation { + typealias ConfigureRequest = + (_ request: NSFetchRequest) -> Void + private let context: NSManagedObjectContext + private let configureClosure: ConfigureRequest? + + /// Create a fetch operation. + /// + /// - Parameters: + /// - context: The context which is used to fetch objects from. + /// - configureClosure: Closure which may be used to customize + /// the fetch request (e.g.: setting sort descriptors or a fetch limit). + init( + context: NSManagedObjectContext, + configureClosure: ConfigureRequest? = nil + ) { + self.context = context + self.configureClosure = configureClosure + + super.init() + + updateReady(true) + } + + override func execute() { + guard let entityName = T.entity().name else { + finish(with: FetchError.entityNotFound) + return + } + + let request = NSFetchRequest(entityName: entityName) + request.predicate = input + configureClosure?(request) + + context.perform { + do { + let result: NSAsynchronousFetchResult? = + try self.context.execute(request) + self.finish(with: result?.finalResult ?? []) + } catch { + self.finish(with: error) + } + } + } +} diff --git a/RAD/Model/Operations/CoreData/SaveContextOperation.swift b/RAD/Model/Operations/CoreData/SaveContextOperation.swift new file mode 100644 index 0000000..5d3c4e1 --- /dev/null +++ b/RAD/Model/Operations/CoreData/SaveContextOperation.swift @@ -0,0 +1,31 @@ +// +// SaveContextOperation.swift +// RAD +// +// Created by David Livadaru on 16/08/2018. +// Copyright © 2018 National Public Radio. All rights reserved. +// + +import Foundation +import CoreData + +/// Saves the contexts if it has pending changes. +class SaveContextOperation: Operation { + private let context: NSManagedObjectContext + + init(context: NSManagedObjectContext) { + self.context = context + } + + override func execute() { + context.perform { + guard self.context.hasChanges else { + self.finish() + return + } + + Storage.shared?.save(context: self.context) + self.finish() + } + } +} diff --git a/RAD/Model/Operations/FilterEventsOperation.swift b/RAD/Model/Operations/FilterEventsOperation.swift new file mode 100644 index 0000000..7cef84d --- /dev/null +++ b/RAD/Model/Operations/FilterEventsOperation.swift @@ -0,0 +1,141 @@ +// +// FilterEventsOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class FilterEventsOperation: InputOperation { + private let ranges: [Range] + private let context: NSManagedObjectContext + + init(ranges: [Range], context: NSManagedObjectContext) { + self.ranges = ranges + self.context = context + } + + override func execute() { + guard let payload = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + context.perform { + guard self.ranges.count > 0 else { + self.deleteTemporaryObjects(from: payload) + self.finish() + return + } + + let result = self.filter(events: payload.events, for: self.ranges) + let unlistenedEvents = result.unused.filter({ + $0.objectID.isTemporaryID && $0.dates?.count == 0 + }) + self.context.deleteObjects([Event](unlistenedEvents)) + self.createRelations(for: result.dates, with: payload) + self.finish() + } + } + + // MARK: Private functionality + + private func deleteTemporaryObjects(from payload: RADPayload) { + var objectsToDelete: [NSManagedObject] = + payload.events.filter({ event in + event.objectID.isTemporaryID && + event.dates?.count == 0 + }) + objectsToDelete += payload.servers.filter({ server in + server.objectID.isTemporaryID && + server.metadataRelations?.count == 0 + }) + let radMetadata = payload.radMetadata + if radMetadata.objectID.isTemporaryID, + radMetadata.metadataRelations?.count == 0 { + objectsToDelete += payload.radMetadata + } + self.context.deleteObjects(objectsToDelete) + } + + private func filter(events: [Event], for ranges: [Range]) -> FilterResult { + var payloadEvents = Set(events) + var savedEvents = Set() + + let sortedRanges = ranges.sorted { (left, right) -> Bool in + guard let lhs = left.start?.date?.intervalSince1970 else { + return false + } + guard let rhs = right.start?.date?.intervalSince1970 else { + return false + } + return lhs < rhs + } + + let dates = sortedRanges.flatMap({ range -> [TimezonedDate] in + let filteredEvents = payloadEvents.filter({ + guard let time = $0.cmEventTime else { return false } + return range.containsTime(time) + }) + savedEvents.formUnion(filteredEvents) + payloadEvents.subtract(filteredEvents) + return computeTimestamp(for: filteredEvents, in: range) + }) + + return FilterResult( + dates: dates, saved: savedEvents, unused: payloadEvents) + } + + private func computeTimestamp( + for events: Set, in range: Range + ) -> [TimezonedDate] { + guard let dateInterval = range.start?.date?.intervalSince1970 else { + return [] + } + guard let timezoneOffset = range.start?.date?.timezoneOffset else { + return [] + } + guard let rangeStartTime = range.start?.playerTime else { return [] } + + var dates: [TimezonedDate] = [] + + events.forEach { + guard let eventTime = $0.cmEventTime?.seconds else { return } + + let date = TimezonedDate(context: context) + let rangeOffset = eventTime - rangeStartTime.seconds + date.intervalSince1970 = dateInterval + rangeOffset + date.timezoneOffset = timezoneOffset + date.event = $0 + dates.append(date) + } + + return dates + } + + private func createRelations( + for dates: [TimezonedDate], with payload: RADPayload + ) { + for server in payload.servers { + let metadataRelation = MetadataRelation(context: self.context) + metadataRelation.sessionId = payload.sessionId + metadataRelation.server = server + metadataRelation.radMetadata = payload.radMetadata + dates.forEach({ date in + date.addToMetadataRelations(metadataRelation) + }) + } + } +} diff --git a/RAD/Model/Operations/ItemSession/ConvertTimeRangeOperation.swift b/RAD/Model/Operations/ItemSession/ConvertTimeRangeOperation.swift new file mode 100644 index 0000000..ce1a47a --- /dev/null +++ b/RAD/Model/Operations/ItemSession/ConvertTimeRangeOperation.swift @@ -0,0 +1,74 @@ +// +// ConvertTimeRangeOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class ConvertTimeRangeOperation: ChainOperation { + private let timeRange: TimeRange + private let context: NSManagedObjectContext + + init(timeRange: TimeRange, context: NSManagedObjectContext) { + self.timeRange = timeRange + self.context = context + } + + override func execute() { + guard let itemSession = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + context.perform { + let range = Range(context: self.context) + range.start = self.createStartBound() + range.end = self.createEndBound() + range.itemSession = itemSession + self.finish(with: [range]) + } + } + + // MARK: Private functionality + + private func createStartBound() -> RangeBound { + let start = RangeBound(context: self.context) + start.playerTime = timeRange.start.playerTime + start.date = createTimezoneDate(from: timeRange.start) + return start + } + + private func createEndBound() -> RangeBound { + let end = RangeBound(context: context) + end.playerTime = timeRange.end.playerTime + end.date = createTimezoneDate(from: timeRange.end) + return end + } + + private func createTimezoneDate( + from bound: TimeRangeBound) -> TimezonedDate? { + guard let intervalSince1970 = bound.intervalSince1970 else { + return nil + } + guard let timezoneOffset = bound.timezoneOffset else { + return nil + } + + let timezonedDate = TimezonedDate(context: context) + timezonedDate.intervalSince1970 = intervalSince1970 + timezonedDate.timezoneOffset = Int64(timezoneOffset) + return timezonedDate + } +} diff --git a/RAD/Model/Operations/ItemSession/CreateItemSessionOperation.swift b/RAD/Model/Operations/ItemSession/CreateItemSessionOperation.swift new file mode 100644 index 0000000..0ac264d --- /dev/null +++ b/RAD/Model/Operations/ItemSession/CreateItemSessionOperation.swift @@ -0,0 +1,247 @@ +// +// CreateItemSessionOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class CreateItemSessionOperation: ChainOperation { + private typealias FetchCompletion = (FetchedSessionData) -> Void + private typealias Completion = () -> Void + + private let context: NSManagedObjectContext + private let sessionContext: NSManagedObjectContext + private let configuration: Configuration + + init( + context: NSManagedObjectContext, + sessionContext: NSManagedObjectContext, + configuration: Configuration + ) { + self.context = context + self.sessionContext = sessionContext + self.configuration = configuration + } + + override func execute() { + guard let radPayload = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + guard let md5Hash = radPayload.md5 else { + finish(with: OutputError.computationError) + return + } + + fetchData(for: md5Hash) { fetchedData in + self.context.perform { + let sessionData = self.extractSessionData( + with: radPayload, md5Hash: md5Hash, from: fetchedData) + + let itemSession = ItemSession(context: self.context) + itemSession.sessionId = sessionData.sessionId + + Storage.shared?.save(context: self.context) + + self.unlockObjects( + startingWith: sessionData.sessionId, unlockCompletion: { + self.finish(with: itemSession) + }) + } + } + } + + private func fetchData( + for md5Hash: String, fetchCompletion: @escaping FetchCompletion + ) { + let radOperations = fetchRadOperations(with: md5Hash) + let sessionIdOperations = fetchSessionIDOperations( + fetchRad: radOperations.fetch) + + var operations = radOperations.all + operations += sessionIdOperations.all + + let completionOperation = BlockOperation { + fetchCompletion(FetchedSessionData( + rad: radOperations.contextFetch.output?.first, + sessionId: sessionIdOperations.contextFetch.output?.first)) + } + operations.forEach({ + completionOperation.addDependency($0) + }) + operations.append(completionOperation) + + OperationQueue.background.addOperations( + operations, waitUntilFinished: false) + } + + private func extractSessionData( + with payload: String, + md5Hash: String, + from optionalSessionData: FetchedSessionData + ) -> SessionData { + let createItemSessionId = { (_ rad: Rad) -> ItemSessionID in + let sessionId = ItemSessionID(context: self.context) + sessionId.creationIntervalSince1970 = Date.now.timeIntervalSince1970 + sessionId.identifier = UUID().uuidString + sessionId.rad = rad + return sessionId + } + + let rad: Rad + let itemSessionId: ItemSessionID + + if let fetchedRad = optionalSessionData.rad { + rad = fetchedRad + if let fetchedItemSession = optionalSessionData.sessionId { + itemSessionId = fetchedItemSession + } else { + itemSessionId = createItemSessionId(rad) + } + } else { + rad = Rad(context: context) + rad.json = payload + rad.md5 = md5Hash + itemSessionId = createItemSessionId(rad) + } + + return SessionData(rad: rad, sessionId: itemSessionId) + } + + private func fetchRadOperations( + with md5Hash: String + ) -> FetchedTypeOperations { + let fetchRad = FetchOperation(context: sessionContext) + fetchRad.input = NSPredicate( + format: "md5 == %@", argumentArray: [md5Hash] + ) + let lockRad = LockRadOperation(context: sessionContext) + fetchRad.chainOperation(with: lockRad) + + let saveSessionContext = SaveContextOperation(context: sessionContext) + saveSessionContext.addDependency(lockRad) + + let transferRad = ContextTransferOperation(context: sessionContext) + fetchRad.chainOperation(with: transferRad) + transferRad.addDependency(saveSessionContext) + + let contextFetchRad = ContextFetchOperation(context: context) + transferRad.chainOperation(with: contextFetchRad) + + return FetchedTypeOperations( + fetch: fetchRad, + contextFetch: contextFetchRad, + all: [ + fetchRad, + lockRad, + saveSessionContext, + transferRad, + contextFetchRad + ]) + } + + private func fetchSessionIDOperations( + fetchRad: FetchOperation + ) -> FetchedTypeOperations { + let itemSessionPredicate = CreateValidSessionPredicateOperation( + configuration: configuration) + fetchRad.chainOperation(with: itemSessionPredicate) + + let fetchItemSessionId = FetchOperation( + context: sessionContext) + + itemSessionPredicate.chainOperation(with: fetchItemSessionId) + let lockSession = LockSessionOperation(context: sessionContext) + fetchItemSessionId.chainOperation(with: lockSession) + + let saveSessionContext = SaveContextOperation(context: sessionContext) + saveSessionContext.addDependency(lockSession) + + let transferSessionId = ContextTransferOperation( + context: sessionContext) + fetchItemSessionId.chainOperation(with: transferSessionId) + transferSessionId.addDependency(saveSessionContext) + + let contextFetchSessionId = ContextFetchOperation( + context: context) + transferSessionId.chainOperation(with: contextFetchSessionId) + + return FetchedTypeOperations( + fetch: fetchItemSessionId, + contextFetch: contextFetchSessionId, + all: [ + itemSessionPredicate, + fetchItemSessionId, + lockSession, + saveSessionContext, + transferSessionId, + contextFetchSessionId + ]) + } + + private func unlockObjects( + startingWith itemSessionId: ItemSessionID, + unlockCompletion: @escaping Completion + ) { + let contextTransfer = ContextTransferOperation( + context: context) + contextTransfer.input = [itemSessionId] + + let sessionContext = self.sessionContext + let contextFetch = ContextFetchOperation( + context: sessionContext) + contextTransfer.chainOperation(with: contextFetch) + + let unlockOperation = AsyncClosureInputOperation<[ItemSessionID]>( + closure: { ids, completion in + sessionContext.perform { + ids.forEach({ + $0.isLocked = false + $0.rad?.isLocked = false + }) + completion() + } + }) + contextFetch.chainOperation(with: unlockOperation) + + let saveOperation = SaveContextOperation(context: sessionContext) + saveOperation.completionBlock = { + unlockCompletion() + } + saveOperation.addDependency(unlockOperation) + + OperationQueue.background.addOperations( + [contextTransfer, contextFetch, unlockOperation, saveOperation], + waitUntilFinished: false) + } +} + +private struct FetchedSessionData { + let rad: Rad? + let sessionId: ItemSessionID? +} + +private struct SessionData { + let rad: Rad + let sessionId: ItemSessionID +} + +private struct FetchedTypeOperations { + let fetch: FetchOperation + let contextFetch: ContextFetchOperation + let all: [Foundation.Operation] +} diff --git a/RAD/Model/Operations/ItemSession/ItemSessionDeactivateOperation.swift b/RAD/Model/Operations/ItemSession/ItemSessionDeactivateOperation.swift new file mode 100644 index 0000000..4a4a4ca --- /dev/null +++ b/RAD/Model/Operations/ItemSession/ItemSessionDeactivateOperation.swift @@ -0,0 +1,48 @@ +// +// ItemSessionDeactivateOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class ItemSessionDeactivateOperation: Operation { + override func execute() { + guard let context = Storage.shared?.createContext else { + self.finish(with: InputError.inconsistentData) + return + } + + let fetchOperation = FetchOperation(context: context) + fetchOperation.input = NSPredicate( + format: "isActive == true", argumentArray: nil) + let deactivateOperation = ClosureInputOperation<[ItemSession]>( + closure: { itemSessions in + context.perform { + itemSessions.forEach({ + $0.isActive = false + }) + } + }) + fetchOperation.chainOperation(with: deactivateOperation) + let saveOperation = SaveContextOperation(context: context) + saveOperation.addDependency(deactivateOperation) + saveOperation.completionBlock = { + self.finish() + } + OperationQueue.background.addOperations( + [fetchOperation, deactivateOperation, saveOperation], + waitUntilFinished: false) + } +} diff --git a/RAD/Model/Operations/ItemSession/ItemSessionOperation.swift b/RAD/Model/Operations/ItemSession/ItemSessionOperation.swift new file mode 100644 index 0000000..e7b8493 --- /dev/null +++ b/RAD/Model/Operations/ItemSession/ItemSessionOperation.swift @@ -0,0 +1,162 @@ +// +// ItemSessionOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import AVFoundation +import CoreData + +protocol ItemSessionOperationDelegate: AnyObject { + func itemSessionOperationSaveCompletionOperation( + _ itemSessionOperation: ItemSessionOperation + ) -> InputOperation<[NSManagedObjectID]> +} + +class ItemSessionOperation: Operation, TimeRangeControllerDelegate { + weak var delegate: ItemSessionOperationDelegate? + + private let asset: AVAsset + private let player: AVPlayer + private var itemSession: ItemSession? + private var creationOperation: CreateItemSessionOperation? + private weak var lastSaveOperation: Operation? + private let timeRangeController: TimeRangeController + private let configuration: Configuration + + init(asset: AVAsset, player: AVPlayer, configuration: Configuration) { + self.asset = asset + self.player = player + self.configuration = configuration + self.timeRangeController = TimeRangeController(player: player) + + super.init() + + timeRangeController.delegate = self + } + + override func execute() { + guard let context = Storage.shared?.createContext else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + guard let sessionContext = Storage.shared?.sessionContext else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + let parseOperation = ParseRADPayloadOperation(asset: asset) + let createItemOperation = CreateItemSessionOperation( + context: context, + sessionContext: sessionContext, + configuration: configuration) + parseOperation.chainOperation(with: createItemOperation) + self.creationOperation = createItemOperation + + let createCompletion = ClosureInputOperation( + closure: { itemSession in + self.itemSession = itemSession + self.creationOperation = nil + }) + createItemOperation.chainOperation(with: createCompletion) + + OperationQueue.background.addOperations( + [parseOperation], waitUntilFinished: false) + OperationQueue.itemSession.addOperations( + [createItemOperation, createCompletion], waitUntilFinished: false) + } + + // MARK: TimeRangeControllerDelegate + + func timeRangeController( + _ timeRangeController: TimeRangeController, + didCreateTimeRange timeRange: TimeRange, + synced: Bool + ) { + guard let context = Storage.shared?.createContext else { return } + + OperationQueue.itemSession.addOperations( + [BlockOperation(block: { + guard self.itemSession != nil || self.creationOperation != nil + else { return } + + let convertOperation = ConvertTimeRangeOperation( + timeRange: timeRange, context: context) + if let itemSession = self.itemSession { + convertOperation.input = itemSession + } else { + self.creationOperation?.chainOperation( + with: convertOperation) + } + + let saveOperation = SaveContextOperation(context: context) + saveOperation.addDependency(convertOperation) + self.lastSaveOperation = saveOperation + let transferOperation = ContextTransferOperation( + context: context) + transferOperation.addDependency(saveOperation) + convertOperation.chainOperation(with: transferOperation) + + var operations: [Operation] = [ + convertOperation, saveOperation, transferOperation] + if let completion = + self.delegate?.itemSessionOperationSaveCompletionOperation( + self) { + transferOperation.chainOperation(with: completion) + operations.append(completion) + } + + OperationQueue.background.addOperations( + operations, waitUntilFinished: synced) + })], + waitUntilFinished: synced) + } + + func timeRangeControllerDidFinishCreatingRanges( + _ timeRangeController: TimeRangeController, synced: Bool + ) { + guard let context = Storage.shared?.createContext else { return } + + OperationQueue.itemSession.addOperations( + [BlockOperation(block: { + let updateItemSession = BlockOperation { + context.performAndWait { + self.itemSession?.isActive = false + } + } + updateItemSession.addDependency(self.lastSaveOperation) + let cleanup = BlockOperation { + guard let itemSession = self.itemSession else { return } + + context.performAndWait { + if itemSession.playbackRanges?.count == 0 { + context.delete(itemSession) + } + } + } + cleanup.addDependency(updateItemSession) + let saveOperation = SaveContextOperation(context: context) + saveOperation.addDependency(cleanup) + saveOperation.completionBlock = { + self.finish() + } + + OperationQueue.background.addOperations( + [updateItemSession, cleanup, saveOperation], + waitUntilFinished: synced) + })], + waitUntilFinished: synced) + } +} diff --git a/RAD/Model/Operations/ItemSession/LockRadOperation.swift b/RAD/Model/Operations/ItemSession/LockRadOperation.swift new file mode 100644 index 0000000..31fa87b --- /dev/null +++ b/RAD/Model/Operations/ItemSession/LockRadOperation.swift @@ -0,0 +1,42 @@ +// +// LockRadOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class LockRadOperation: InputOperation<[Rad]> { + private let lock: Bool + private let context: NSManagedObjectContext + + init(lock: Bool = true, context: NSManagedObjectContext) { + self.lock = lock + self.context = context + } + + override func execute() { + guard let objects = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + context.perform { + objects.forEach({ + $0.isLocked = self.lock + }) + self.finish() + } + } +} diff --git a/RAD/Model/Operations/ItemSession/LockSessionOperation.swift b/RAD/Model/Operations/ItemSession/LockSessionOperation.swift new file mode 100644 index 0000000..9c4b3e6 --- /dev/null +++ b/RAD/Model/Operations/ItemSession/LockSessionOperation.swift @@ -0,0 +1,42 @@ +// +// LockSessionOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class LockSessionOperation: InputOperation<[ItemSessionID]> { + private let lock: Bool + private let context: NSManagedObjectContext + + init(lock: Bool = true, context: NSManagedObjectContext) { + self.lock = lock + self.context = context + } + + override func execute() { + guard let objects = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + context.perform { + objects.forEach({ + $0.isLocked = self.lock + }) + self.finish() + } + } +} diff --git a/RAD/Model/Operations/ItemSession/ProcessItemCleanupOperation.swift b/RAD/Model/Operations/ItemSession/ProcessItemCleanupOperation.swift new file mode 100644 index 0000000..834780b --- /dev/null +++ b/RAD/Model/Operations/ItemSession/ProcessItemCleanupOperation.swift @@ -0,0 +1,108 @@ +// +// ProcessItemCleanupOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class ProcessItemCleanupOperation: Operation { + private let context: NSManagedObjectContext + private let sessionContext: NSManagedObjectContext + private let configuration: Configuration + + init( + context: NSManagedObjectContext, + sessionContext: NSManagedObjectContext, + configuration: Configuration + ) { + self.context = context + self.sessionContext = sessionContext + self.configuration = configuration + } + + override func execute() { + let itemOperations = createItemSessionOperations() + let radOperations = createRadOperations( + dependentWith: itemOperations.last) + + let saveOperation = SaveContextOperation(context: context) + saveOperation.completionBlock = { + self.finish() + } + saveOperation.addDependency(itemOperations.last) + saveOperation.addDependency(radOperations.last) + + var operations = itemOperations + operations += radOperations + operations += saveOperation + + OperationQueue.background.addOperations( + operations, waitUntilFinished: false) + } + + // MARK: Private functionality + + private func createItemSessionOperations() -> [Foundation.Operation] { + let predicateOperation = OldItemSessionIDOperation( + configuration: configuration) + let fetchItemSessionIDs = FetchOperation( + context: sessionContext) + predicateOperation.chainOperation(with: fetchItemSessionIDs) + let transferItemSessionIDs = ContextTransferOperation( + context: sessionContext) + fetchItemSessionIDs.chainOperation(with: transferItemSessionIDs) + let sessionContextFetch = ContextFetchOperation( + context: context) + transferItemSessionIDs.chainOperation(with: sessionContextFetch) + let deleteItemSessionIds = DeleteOperation( + context: context) + sessionContextFetch.chainOperation(with: deleteItemSessionIds) + let saveOperation = SaveContextOperation(context: context) + saveOperation.addDependency(deleteItemSessionIds) + + return [ + predicateOperation, + fetchItemSessionIDs, + transferItemSessionIDs, + sessionContextFetch, + deleteItemSessionIds, + saveOperation] + } + + private func createRadOperations( + dependentWith dependent: Foundation.Operation? + ) -> [Foundation.Operation] { + let createRadPredicate = UnlockedRadPredicateOperation() + if let dependent = dependent { + createRadPredicate.addDependency(dependent) + } + let fetchRadObjects = FetchOperation(context: sessionContext) + createRadPredicate.chainOperation(with: fetchRadObjects) + let transferRad = ContextTransferOperation(context: sessionContext) + fetchRadObjects.chainOperation(with: transferRad) + let radContextFetch = ContextFetchOperation(context: context) + transferRad.chainOperation(with: radContextFetch) + let deleteRadObjects = DeleteOperation(context: context) + radContextFetch.chainOperation(with: deleteRadObjects) + + return [ + createRadPredicate, + fetchRadObjects, + transferRad, + radContextFetch, + deleteRadObjects] + } +} diff --git a/RAD/Model/Operations/ItemSession/ProcessItemSessionsOperation.swift b/RAD/Model/Operations/ItemSession/ProcessItemSessionsOperation.swift new file mode 100644 index 0000000..f8d1b47 --- /dev/null +++ b/RAD/Model/Operations/ItemSession/ProcessItemSessionsOperation.swift @@ -0,0 +1,116 @@ +// +// ProcessItemSessionsOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class ProcessItemSessionsOperation: InputOperation<[ItemSession]> { + private let context: NSManagedObjectContext + private let sessionContext: NSManagedObjectContext + private let configuration: Configuration + + init( + context: NSManagedObjectContext, + sessionContext: NSManagedObjectContext, + configuration: Configuration + ) { + self.context = context + self.sessionContext = sessionContext + self.configuration = configuration + } + + override func execute() { + guard let itemSessions = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + context.perform { + self.performOperations(itemSessions: itemSessions) + } + } + + // MARK: Private functionality + + private func performOperations(itemSessions: [ItemSession]) { + let saveOperation = SaveContextOperation(context: context) + let cleanupOperation = ProcessItemCleanupOperation( + context: context, + sessionContext: sessionContext, + configuration: configuration) + cleanupOperation.completionBlock = { self.finish() } + cleanupOperation.addDependency(saveOperation) + var operations = [saveOperation, cleanupOperation] + var previousFilterOperation: FilterEventsOperation? + itemSessions.forEach({ itemSession in + previousFilterOperation = self.process( + itemSession, + dependingOn: previousFilterOperation, + with: saveOperation, + from: &operations) + }) + OperationQueue.background.addOperations( + operations, waitUntilFinished: false) + } + + private func process( + _ itemSession: ItemSession, + dependingOn previousFilter: FilterEventsOperation?, + with saveOperation: Operation, + from operations: inout [Operation] + ) -> FilterEventsOperation? { + guard let json: String = itemSession.sessionId?.rad?.json + else { return nil } + guard let ranges = itemSession.playbackRanges?.allObjects as? [Range] + else { return nil } + guard let sessionIdentifier = itemSession.sessionId?.identifier + else { return nil } + guard let md5 = itemSession.sessionId?.rad?.md5 else { return nil } + + let parseJson = ParseJSONOperation() + parseJson.input = json + let parsePayload = ParseRADObjectsOperation( + context: self.context, + sessionId: sessionIdentifier, + md5: md5) + parsePayload.addDependency(previousFilter) + parseJson.chainOperation(with: parsePayload) + let filterEvents = FilterEventsOperation( + ranges: ranges, context: context) + parsePayload.chainOperation(with: filterEvents) + operations += [parseJson, parsePayload, filterEvents] + let deleteOperation: Operation + + if !itemSession.isActive { + let deleteItemOperation = DeleteOperation( + context: context) + deleteItemOperation.input = [itemSession] + deleteOperation = deleteItemOperation + } else { + let deleteRangesOperation = DeleteOperation( + context: context) + deleteRangesOperation.input = ranges + deleteOperation = deleteRangesOperation + } + + deleteOperation.addDependency(filterEvents) + saveOperation.addDependency(deleteOperation) + operations.append(deleteOperation) + + return filterEvents + } +} diff --git a/RAD/Model/Operations/ItemSession/UnlockObjectsOperation.swift b/RAD/Model/Operations/ItemSession/UnlockObjectsOperation.swift new file mode 100644 index 0000000..def532d --- /dev/null +++ b/RAD/Model/Operations/ItemSession/UnlockObjectsOperation.swift @@ -0,0 +1,51 @@ +// +// UnlockObjectsOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class UnlockObjectsOperation: Operation { + override func execute() { + guard let context = Storage.shared?.sessionContext else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + let predicate = NSPredicate( + format: "isLocked == true", argumentArray: nil) + + let fetchIds = FetchOperation(context: context) + fetchIds.input = predicate + let unlockIds = LockSessionOperation(lock: false, context: context) + fetchIds.chainOperation(with: unlockIds) + + let fetchRad = FetchOperation(context: context) + fetchRad.input = predicate + let unlockRad = LockRadOperation(lock: false, context: context) + fetchRad.chainOperation(with: unlockRad) + + let saveOperation = SaveContextOperation(context: context) + saveOperation.completionBlock = { + self.finish() + } + saveOperation.addDependency(unlockIds) + saveOperation.addDependency(unlockRad) + + OperationQueue.background.addOperations( + [fetchIds, unlockIds, fetchRad, unlockRad, saveOperation], + waitUntilFinished: false) + } +} diff --git a/RAD/Model/Operations/JsonProcessing/ParseJSONOperation.swift b/RAD/Model/Operations/JsonProcessing/ParseJSONOperation.swift new file mode 100644 index 0000000..ae47f91 --- /dev/null +++ b/RAD/Model/Operations/JsonProcessing/ParseJSONOperation.swift @@ -0,0 +1,47 @@ +// +// ParseJSONOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// Parsing the json from string. +/// Operation may fail if string is not formatted as JSON, +// or the json object cannot be converted to expected JSON object type. +class ParseJSONOperation: ChainOperation { + override func execute() { + guard let stringJson = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + guard let data = stringJson.data(using: .utf8) else { + finish(with: ParseError.unableToParseJson) + return + } + + do { + let rawJSON = try JSONSerialization.jsonObject( + with: data, options: []) + if let json = rawJSON as? JSON { + finish(with: json) + } else { + finish(with: ParseError.unableToParseJson) + } + } catch { + finish(with: error) + } + } +} diff --git a/RAD/Model/Operations/JsonProcessing/ParseRADPayloadOperation.swift b/RAD/Model/Operations/JsonProcessing/ParseRADPayloadOperation.swift new file mode 100644 index 0000000..aa78e9b --- /dev/null +++ b/RAD/Model/Operations/JsonProcessing/ParseRADPayloadOperation.swift @@ -0,0 +1,49 @@ +// +// ParseRADPayloadOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import AVFoundation + +/// Extract RAD payload from an AVAsset. +/// If payload cannot be found, it will finish with an error. +class ParseRADPayloadOperation: OutputOperation { + private let asset: AVAsset + + init(asset: AVAsset) { + self.asset = asset + } + + override func execute() { + let metadata: [String: String] = + asset.availableMetadataFormats.reduce([:], { result, format in + var new = result + let formatMetadata = asset.metadata(forFormat: format) + formatMetadata.forEach({ + new[format.rawValue] = $0.stringValue + }) + return new + }) + + guard let radPair = metadata.first(where: { + $0.value.contains(RadMetadata.JSONProperty.remoteAudioData.rawValue) + }) else { + finish(with: ParseError.radPayloadNotFound) + return + } + + finish(with: radPair.value) + } +} diff --git a/RAD/Model/Operations/JsonProcessing/PrettyJSONOperation.swift b/RAD/Model/Operations/JsonProcessing/PrettyJSONOperation.swift new file mode 100644 index 0000000..8fadb75 --- /dev/null +++ b/RAD/Model/Operations/JsonProcessing/PrettyJSONOperation.swift @@ -0,0 +1,41 @@ +// +// PrettyJSONOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// Format a json object in order to ready for print +/// (e.g.: display the json on UI.) +class PrettyJSONOperation: ChainOperation { + override func execute() { + guard let json = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + do { + let data = try JSONSerialization.data( + withJSONObject: json, options: [.prettyPrinted]) + if let prettyJson = String(data: data, encoding: .utf8) { + finish(with: prettyJson) + } else { + finish(with: OutputError.computationError) + } + } catch { + finish(with: error) + } + } +} diff --git a/RAD/Model/Operations/ObjectConversionOperation.swift b/RAD/Model/Operations/ObjectConversionOperation.swift new file mode 100644 index 0000000..c424dd1 --- /dev/null +++ b/RAD/Model/Operations/ObjectConversionOperation.swift @@ -0,0 +1,38 @@ +// +// ObjectConversionOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import CoreData + +class ObjectConversionOperation: +ChainOperation<[T], [Object]> { + private let context: NSManagedObjectContext + + init(context: NSManagedObjectContext) { + self.context = context + } + + override func execute() { + guard let databaseObjects = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + context.perform { + let convertedObjects = databaseObjects.map({ $0.object }) + self.finish(with: convertedObjects) + } + } +} diff --git a/RAD/Model/Operations/ParseRADObjectsOperation.swift b/RAD/Model/Operations/ParseRADObjectsOperation.swift new file mode 100644 index 0000000..b4a22d7 --- /dev/null +++ b/RAD/Model/Operations/ParseRADObjectsOperation.swift @@ -0,0 +1,168 @@ +// +// ParseRADObjectsOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class ParseRADObjectsOperation: ChainOperation { + let context: NSManagedObjectContext + private let sessionId: String + private let md5: String + + private var md5Predicate: NSPredicate { + return NSPredicate(format: "md5 LIKE %@", argumentArray: [md5]) + } + + init(context: NSManagedObjectContext, sessionId: String, md5: String) { + self.context = context + self.sessionId = sessionId + self.md5 = md5 + } + + override func execute() { + guard let json = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + guard let radJSon = + json[RadMetadata.JSONProperty.remoteAudioData] as? JSONDictionary + else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + context.perform { + let servers = self.parseServers(with: radJSon) + guard servers.count > 0 else { + self.finish(with: ParseError.unableToParseJson) + return + } + var radMetadataJson = radJSon + radMetadataJson[Server.JSONProperty.trackingUrls] = nil + + guard let radMetadata = self.fetchRadMetadata(with: radMetadataJson) + else { + self.finish(with: ParseError.unableToParseJson) + return + } + guard let metadataEvents = + radJSon[RadMetadata.JSONProperty.events] as? JSONArray else { + self.finish(with: ParseError.unableToParseJson) + return + } + + let events = metadataEvents.compactMap({ + self.fetchEvent(with: $0) + }) + + let payload = RADPayload( + servers: servers, + radMetadata: radMetadata, + events: events, + sessionId: self.sessionId) + self.finish(with: payload) + } + } + + private func parseServers( + with json: JSONDictionary + ) -> [Server] { + guard let urls = + json[Server.JSONProperty.trackingUrls] as? [String] else { + return [] + } + let servers = urls.compactMap({ + self.fetchServer(with: $0, json: json) + }) + return servers + } + + private func fetchServer( + with url: String, json: JSONDictionary + ) -> Server? { + do { + let request: NSFetchRequest = Server.fetchRequest() + request.predicate = NSPredicate( + format: "trackingUrl LIKE %@", argumentArray: [url]) + request.fetchLimit = 1 + let result: NSAsynchronousFetchResult? = + try context.execute(request) + if let server = result?.finalResult?.first { + return server + } else { + return Server(urlString: url, context: context) + } + } catch { + var message: String = "Unable to fetch server with trackingUrl: " + message += "\(url) due to error: \(error)." + print(message) + return nil + } + } + + private func fetchRadMetadata(with json: JSONDictionary) -> RadMetadata? { + do { + let request: NSFetchRequest = + RadMetadata.fetchRequest() + request.predicate = md5Predicate + request.fetchLimit = 1 + let result: NSAsynchronousFetchResult? = + try context.execute(request) + if let radMetadata = result?.finalResult?.first { + return radMetadata + } else { + let radMetadata = RadMetadata(json: json, context: context) + radMetadata?.md5 = md5 + return radMetadata + } + } catch { + let message: String = "Unable to fetch radMetadata with md5: \(md5)" + print(message) + return nil + } + } + + private func fetchEvent(with json: JSONDictionary) -> Event? { + guard let eventTime = json[Event.JSONProperty.eventTime] as? String + else { return nil } + do { + let request: NSFetchRequest = Event.fetchRequest() + let eventTimePredicate = NSPredicate( + format: "eventTime LIKE %@", argumentArray: [eventTime]) + request.predicate = NSCompoundPredicate( + andPredicateWithSubpredicates: [ + md5Predicate, eventTimePredicate + ]) + request.fetchLimit = 1 + let result: NSAsynchronousFetchResult? = + try context.execute(request) + if let event = result?.finalResult?.first { + return event + } else { + let event = Event(json: json, context: context) + event?.md5 = md5 + return event + } + } catch { + var message: String = "Unable to fetch event with event time: " + message += "\(eventTime), md5: \(md5)" + print(message) + return nil + } + } +} diff --git a/RAD/Model/Operations/PredicateCreation/CreateEmptyObjectPredicateOperation.swift b/RAD/Model/Operations/PredicateCreation/CreateEmptyObjectPredicateOperation.swift new file mode 100644 index 0000000..59098ae --- /dev/null +++ b/RAD/Model/Operations/PredicateCreation/CreateEmptyObjectPredicateOperation.swift @@ -0,0 +1,31 @@ +// +// CreateEmptyObjectPredicateOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class CreateEmptyObjectPredicateOperation: OutputOperation, +EmptyObjectBuilder { + private let relation: String + + init(relation: String) { + self.relation = relation + } + + override func execute() { + finish(with: createEmptyObjectPredicate(for: relation)) + } +} diff --git a/RAD/Model/Operations/PredicateCreation/CreateOldEventsOperation.swift b/RAD/Model/Operations/PredicateCreation/CreateOldEventsOperation.swift new file mode 100644 index 0000000..89ff202 --- /dev/null +++ b/RAD/Model/Operations/PredicateCreation/CreateOldEventsOperation.swift @@ -0,0 +1,45 @@ +// +// CreateOldEventsOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class CreateOldEventsOperation: OutputOperation { + private let configuration: Configuration + + init(configuration: Configuration) { + self.configuration = configuration + } + + override func execute() { + let calendar = Calendar(identifier: .gregorian) + let dateComponents = -configuration.expirationTimeInterval + + guard let date = calendar.date( + byAdding: dateComponents, to: Date.now) else { + finish(with: OutputError.computationError) + return + } + let datePredicate = NSPredicate( + format: "intervalSince1970 < %@", + argumentArray: [date.timeIntervalSince1970]) + let relationPredicate = NSPredicate( + format: "event != nil", argumentArray: nil) + let compoundPredicate = NSCompoundPredicate( + andPredicateWithSubpredicates: [datePredicate, relationPredicate]) + finish(with: compoundPredicate) + } +} diff --git a/RAD/Model/Operations/PredicateCreation/CreateValidSessionPredicateOperation.swift b/RAD/Model/Operations/PredicateCreation/CreateValidSessionPredicateOperation.swift new file mode 100644 index 0000000..bc89a2c --- /dev/null +++ b/RAD/Model/Operations/PredicateCreation/CreateValidSessionPredicateOperation.swift @@ -0,0 +1,44 @@ +// +// CreateValidSessionPredicateOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class CreateValidSessionPredicateOperation: ChainOperation<[Rad], NSPredicate>, +ExpiredSessionIDBuilder { + let configuration: Configuration + + init(configuration: Configuration) { + self.configuration = configuration + } + + override func execute() { + guard let rad = input?.first else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + let relationPredicate = NSPredicate( + format: "rad == %@", argumentArray: [rad]) + let validationPredicate = NSCompoundPredicate( + notPredicateWithSubpredicate: createExpiredSessionIDPredicate()) + + let predicate = NSCompoundPredicate( + andPredicateWithSubpredicates: [ + relationPredicate, validationPredicate]) + finish(with: predicate) + } +} diff --git a/RAD/Model/Operations/PredicateCreation/EmptyObjectBuilder.swift b/RAD/Model/Operations/PredicateCreation/EmptyObjectBuilder.swift new file mode 100644 index 0000000..d18c1b2 --- /dev/null +++ b/RAD/Model/Operations/PredicateCreation/EmptyObjectBuilder.swift @@ -0,0 +1,29 @@ +// +// EmptyObjectBuilder.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +protocol EmptyObjectBuilder { + func createEmptyObjectPredicate(for relation: String) -> NSPredicate +} + +extension EmptyObjectBuilder { + func createEmptyObjectPredicate(for relation: String) -> NSPredicate { + return NSPredicate( + format: "\(relation).@count == 0", argumentArray: nil) + } +} diff --git a/RAD/Model/Operations/PredicateCreation/ExpiredSessionIDBuilder.swift b/RAD/Model/Operations/PredicateCreation/ExpiredSessionIDBuilder.swift new file mode 100644 index 0000000..90bb502 --- /dev/null +++ b/RAD/Model/Operations/PredicateCreation/ExpiredSessionIDBuilder.swift @@ -0,0 +1,34 @@ +// +// ExpiredSessionIDBuilder.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +protocol ExpiredSessionIDBuilder { + var configuration: Configuration { get } + + func createExpiredSessionIDPredicate() -> NSPredicate +} + +extension ExpiredSessionIDBuilder { + func createExpiredSessionIDPredicate() -> NSPredicate { + let currentTime = Date.now.timeIntervalSince1970 + let delta = configuration.sessionExpirationTimeInterval + return NSPredicate( + format: "\(currentTime) - creationIntervalSince1970 > \(delta)", + argumentArray: nil) + } +} diff --git a/RAD/Model/Operations/PredicateCreation/OldItemSessionIDOperation.swift b/RAD/Model/Operations/PredicateCreation/OldItemSessionIDOperation.swift new file mode 100644 index 0000000..5c90c6f --- /dev/null +++ b/RAD/Model/Operations/PredicateCreation/OldItemSessionIDOperation.swift @@ -0,0 +1,38 @@ +// +// OldItemSessionIDOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class OldItemSessionIDOperation: OutputOperation, +EmptyObjectBuilder, ExpiredSessionIDBuilder, UnlockedObjectBuilder { + let configuration: Configuration + + init(configuration: Configuration) { + self.configuration = configuration + } + + override func execute() { + let noSessions = createEmptyObjectPredicate(for: "itemSessions") + let expiredSessions = createExpiredSessionIDPredicate() + let unlockedSessions = createUnlockedPredicate() + let compoundPredicate = NSCompoundPredicate( + andPredicateWithSubpredicates: [ + noSessions, expiredSessions, unlockedSessions + ]) + finish(with: compoundPredicate) + } +} diff --git a/RAD/Model/Operations/PredicateCreation/SentTimezonedDatePredicateOperation.swift b/RAD/Model/Operations/PredicateCreation/SentTimezonedDatePredicateOperation.swift new file mode 100644 index 0000000..65bbf80 --- /dev/null +++ b/RAD/Model/Operations/PredicateCreation/SentTimezonedDatePredicateOperation.swift @@ -0,0 +1,33 @@ +// +// SentTimezonedDatePredicateOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class SentTimezonedDatePredicateOperation: +ChainOperation { + override func execute() { + guard let input = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + let noRangePredicate = NSPredicate( + format: "rangeBound == nil", argumentArray: nil) + let predicate = NSCompoundPredicate( + andPredicateWithSubpredicates: [input, noRangePredicate]) + finish(with: predicate) + } +} diff --git a/RAD/Model/Operations/PredicateCreation/UnlockedRadOperation.swift b/RAD/Model/Operations/PredicateCreation/UnlockedRadOperation.swift new file mode 100644 index 0000000..1a630cc --- /dev/null +++ b/RAD/Model/Operations/PredicateCreation/UnlockedRadOperation.swift @@ -0,0 +1,29 @@ +// +// UnlockedRadOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class UnlockedRadPredicateOperation: OutputOperation, +EmptyObjectBuilder, UnlockedObjectBuilder { + override func execute() { + let emptyObject = createEmptyObjectPredicate(for: "itemSessionIds") + let unlocked = createUnlockedPredicate() + let compoundPredicate = NSCompoundPredicate( + andPredicateWithSubpredicates: [emptyObject, unlocked]) + finish(with: compoundPredicate) + } +} diff --git a/RAD/Model/Operations/PredicateCreation/UnlockedSessionBuilder.swift b/RAD/Model/Operations/PredicateCreation/UnlockedSessionBuilder.swift new file mode 100644 index 0000000..6ba9eb3 --- /dev/null +++ b/RAD/Model/Operations/PredicateCreation/UnlockedSessionBuilder.swift @@ -0,0 +1,34 @@ +// +// UnlockedObjectBuilder.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +protocol UnlockedObjectBuilder { + func createUnlockedPredicate() -> NSPredicate + func createUnlockedPredicate(unlocked: Bool) -> NSPredicate +} + +extension UnlockedObjectBuilder { + func createUnlockedPredicate() -> NSPredicate { + return createUnlockedPredicate(unlocked: true) + } + + func createUnlockedPredicate(unlocked: Bool) -> NSPredicate { + return NSPredicate( + format: "isLocked == \(!unlocked)", argumentArray: nil) + } +} diff --git a/RAD/Model/Operations/Scheduling/AggregateOperation.swift b/RAD/Model/Operations/Scheduling/AggregateOperation.swift new file mode 100644 index 0000000..9c759fe --- /dev/null +++ b/RAD/Model/Operations/Scheduling/AggregateOperation.swift @@ -0,0 +1,41 @@ +// +// AggregateOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class AggregateOperation: OutputOperation { + private var results = Set() + + func add(_ result: UIBackgroundFetchResult) { + results.insert(result) + } + + override func execute() { + let results: [UIBackgroundFetchResult] = [.newData, .noData, .failed] + let foundResult = results.first(where: { + return self.results.contains($0) + }) + var output: UIBackgroundFetchResult = .failed + if let foundResult = foundResult { + output = foundResult + } else if self.results.count == 0 { + output = .noData + } + + finish(with: output) + } +} diff --git a/RAD/Model/Operations/Scheduling/BatchEventsOperation.swift b/RAD/Model/Operations/Scheduling/BatchEventsOperation.swift new file mode 100644 index 0000000..e958b4f --- /dev/null +++ b/RAD/Model/Operations/Scheduling/BatchEventsOperation.swift @@ -0,0 +1,88 @@ +// +// BatchEventsOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class BatchEventsOperation: OutputOperation<[Batch]> { + typealias AppendingBatch = (_: inout Batch, _: inout [Batch]) -> Void + private let configuration: Configuration + private let server: Server + private let context: NSManagedObjectContext + + init( + configuration: Configuration, + server: Server, + context: NSManagedObjectContext + ) { + self.configuration = configuration + self.server = server + self.context = context + } + + override func execute() { + context.perform { + var batches: [Batch] = [] + var currentBatch = Batch(server: self.server) + let batchSize = Int(self.configuration.batchSize) + self.server.metadataRelationsArray.forEach({ + let requiredCount = batchSize - currentBatch.datesCount + + let availableDates = $0.timezonedDates.sorted(by: { + $0.intervalSince1970 < $1.intervalSince1970 + }) + let count = min(requiredCount, availableDates.count) + let groupedDates = [TimezonedDate](availableDates[.. Int = { + return count + $0 * batchSize + } + while indexCompute(index) < availableDates.count { + let upperBound = min( + indexCompute(index + 1), availableDates.count) + let range = indexCompute(index).. 0, + currentBatch.datesCount < batchSize { + batches.append(currentBatch) + } + + self.finish(with: batches) + } + } +} diff --git a/RAD/Model/Operations/Scheduling/ConvertBatchOperation.swift b/RAD/Model/Operations/Scheduling/ConvertBatchOperation.swift new file mode 100644 index 0000000..49b03e7 --- /dev/null +++ b/RAD/Model/Operations/Scheduling/ConvertBatchOperation.swift @@ -0,0 +1,46 @@ +// +// ConvertBatchOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class ConvertBatchOperation: ChainOperation { + let configuration: Configuration + let context: NSManagedObjectContext + + init(configuration: Configuration, context: NSManagedObjectContext) { + self.configuration = configuration + self.context = context + } + + override func execute() { + context.perform { + guard let batch = self.input else { + self.finish(with: InputError.requiredDataNotAvailable) + return + } + + let builder = RequestBuilder(batch: batch) + if let request = builder.buildRequest(with: self.configuration) { + self.finish(with: + ConversionResult(batch: batch, request: request)) + } else { + self.finish(with: OutputError.computationError) + } + } + } +} diff --git a/RAD/Model/Operations/Scheduling/FindNextSchedule.swift b/RAD/Model/Operations/Scheduling/FindNextSchedule.swift new file mode 100644 index 0000000..0d7f1b6 --- /dev/null +++ b/RAD/Model/Operations/Scheduling/FindNextSchedule.swift @@ -0,0 +1,38 @@ +// +// FindNextSchedule.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class FindNextSchedule: OutputOperation { + private let configuration: Configuration + + init(configuration: Configuration) { + self.configuration = configuration + } + + override func execute() { + let next = nextSchedule() + let interval = next.timeIntervalSinceNow + finish(with: interval) + } + + private func nextSchedule() -> Date { + let lastSchedule = Scheduling.last + return lastSchedule.addingTimeInterval( + configuration.submissionTimeInterval) + } +} diff --git a/RAD/Model/Operations/Scheduling/NetworkOperation.swift b/RAD/Model/Operations/Scheduling/NetworkOperation.swift new file mode 100644 index 0000000..2ea3ef8 --- /dev/null +++ b/RAD/Model/Operations/Scheduling/NetworkOperation.swift @@ -0,0 +1,39 @@ +// +// NetworkOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class NetworkOperation: ChainOperation { + override func execute() { + guard let conversionResult = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + NetworkService.shared.executeRequest( + conversionResult.request, completion: { (response, _) in + guard let code = response?.statusCode else { + self.finish(with: OutputError.computationError) + return + } + let statusCode = HttpStatusCode.with(code) + let output = NetworkResult( + status: statusCode, batch: conversionResult.batch) + self.finish(with: output) + }) + } +} diff --git a/RAD/Model/Operations/Scheduling/ProcessBatchesOperation.swift b/RAD/Model/Operations/Scheduling/ProcessBatchesOperation.swift new file mode 100644 index 0000000..3db17f3 --- /dev/null +++ b/RAD/Model/Operations/Scheduling/ProcessBatchesOperation.swift @@ -0,0 +1,76 @@ +// +// ProcessBatchesOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class ProcessBatchesOperation: +ChainOperation<[Batch], UIBackgroundFetchResult> { + private let configuration: Configuration + private let context: NSManagedObjectContext + + init(configuration: Configuration, context: NSManagedObjectContext) { + self.configuration = configuration + self.context = context + } + + override func execute() { + guard let batches = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + let aggregationOperation = AggregateOperation() + let finishOperation = ClosureInputOperation( + closure: { + self.finish(with: $0) + }) + aggregationOperation.chainOperation(with: finishOperation) + + var operations: [Foundation.Operation] = [ + aggregationOperation, finishOperation + ] + + batches.forEach { + let conversionOperation = ConvertBatchOperation( + configuration: configuration, context: context) + conversionOperation.input = $0 + let networkOperation = NetworkOperation() + conversionOperation.chainOperation(with: networkOperation) + let responseCheckOperation = ResponseCheckOperation( + context: context) + networkOperation.chainOperation(with: responseCheckOperation) + let batchCompletion = + ClosureInputOperation( + closure: { [weak aggregationOperation] in + aggregationOperation?.add($0) + }) + responseCheckOperation.chainOperation(with: batchCompletion) + aggregationOperation.addDependency(batchCompletion) + operations += [ + conversionOperation, + networkOperation, + responseCheckOperation, + batchCompletion + ] + } + + OperationQueue.background.addOperations( + operations, waitUntilFinished: false) + + } +} diff --git a/RAD/Model/Operations/Scheduling/ResponseCheckOperation.swift b/RAD/Model/Operations/Scheduling/ResponseCheckOperation.swift new file mode 100644 index 0000000..3c751a2 --- /dev/null +++ b/RAD/Model/Operations/Scheduling/ResponseCheckOperation.swift @@ -0,0 +1,56 @@ +// +// ResponseCheckOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class ResponseCheckOperation: +ChainOperation { + private let context: NSManagedObjectContext + + init(context: NSManagedObjectContext) { + self.context = context + } + + override func execute() { + guard let result = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + switch result.status.`class` { + case .success, .client: + let deleteRelations = BlockOperation(block: { + self.context.performAndWait({ + result.batch.groups.forEach({ + $0.metadata.removeFromDates(NSSet(array: $0.dates)) + }) + }) + }) + let saveOperation = SaveContextOperation(context: context) + saveOperation.addDependency(deleteRelations) + let completion = BlockOperation { + self.finish(with: .newData) + } + completion.addDependency(saveOperation) + OperationQueue.background.addOperations( + [deleteRelations, saveOperation, completion], + waitUntilFinished: false) + default: + self.finish(with: .noData) + } + } +} diff --git a/RAD/Model/Operations/Scheduling/ScheduleDataSend.swift b/RAD/Model/Operations/Scheduling/ScheduleDataSend.swift new file mode 100644 index 0000000..2989e4b --- /dev/null +++ b/RAD/Model/Operations/Scheduling/ScheduleDataSend.swift @@ -0,0 +1,138 @@ +// +// ScheduleDataSend.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class ScheduleDataSend: Operation { + typealias DataSendCompletion = (UIBackgroundFetchResult) -> Void + private let configuration: Configuration + private var repeats: Bool + private let sentCompletion: DataSendCompletion? + + static func scheduleDataSend(configuration: Configuration) { + let findNextSchedule = FindNextSchedule(configuration: configuration) + let currentScheduleOperation = + OperationQueue.background.operations.first(where: { + return $0 is ScheduleDataSend && $0.isExecuting + }) + findNextSchedule.addDependency(currentScheduleOperation) + let waitOperation = WaitOperation() + findNextSchedule.chainOperation(with: waitOperation) + let scheduleData = ScheduleDataSend(configuration: configuration) + scheduleData.addDependency(waitOperation) + + OperationQueue.background.addOperations( + [findNextSchedule, waitOperation, scheduleData], + waitUntilFinished: false) + } + + static func cancelScheduledDataSend() { + guard let scheduleOperations = + OperationQueue.background.operations.filter({ + $0 is ScheduleDataSend + }) as? [ScheduleDataSend] else { return } + var executing: [ScheduleDataSend] = [] + var notExecuting: [ScheduleDataSend] = [] + scheduleOperations.forEach({ + if $0.isExecuting { + executing.append($0) + } else { + notExecuting.append($0) + } + }) + executing.forEach({ $0.stopRepeating() }) + notExecuting.forEach({ $0.cancel() }) + } + + init( + configuration: Configuration, + repeats: Bool = true, + sentCompletion: DataSendCompletion? = nil + ) { + self.configuration = configuration + self.repeats = repeats + self.sentCompletion = sentCompletion + } + + func stopRepeating() { + repeats = false + } + + override func execute() { + guard let context = Storage.shared?.deleteContext else { + finish(with: InputError.requiredDataNotAvailable) + return + } + guard let sessionContext = Storage.shared?.sessionContext else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + performOperations(with: context, and: sessionContext) + } + + private func performOperations( + with context: NSManagedObjectContext, + and sessionContext: NSManagedObjectContext + ) { + let fetchOperation = FetchOperation(context: context) + let processOperation = ProcessItemSessionsOperation( + context: context, + sessionContext: sessionContext, + configuration: configuration) + fetchOperation.chainOperation(with: processOperation) + let createOldEventsPredicate = CreateOldEventsOperation( + configuration: configuration) + createOldEventsPredicate.addDependency(processOperation) + let fetchEvents = FetchOperation(context: context) + createOldEventsPredicate.chainOperation(with: fetchEvents) + let deleteEvents = DeleteOperation(context: context) + fetchEvents.chainOperation(with: deleteEvents) + let saveOperation = SaveContextOperation(context: context) + saveOperation.addDependency(deleteEvents) + let fetchServers = FetchOperation(context: context) + fetchServers.addDependency(saveOperation) + let sendDataOperation = SendDataOperation( + configuration: configuration, context: context) + fetchServers.chainOperation(with: sendDataOperation) + let sentCompletionClosure = self.sentCompletion + let sentCompletionOperation = ClosureInputOperation( + closure: { [weak self] in + sentCompletionClosure?($0) + guard let strongSelf = self else { return } + if strongSelf.repeats { + ScheduleDataSend.scheduleDataSend( + configuration: strongSelf.configuration) + } + strongSelf.finish() + }) + sendDataOperation.chainOperation(with: sentCompletionOperation) + + OperationQueue.background.addOperations( + [fetchOperation, + processOperation, + createOldEventsPredicate, + fetchEvents, + deleteEvents, + saveOperation, + fetchServers, + sendDataOperation, + sentCompletionOperation], + waitUntilFinished: false) + } +} diff --git a/RAD/Model/Operations/Scheduling/SendDataCleanupOperation.swift b/RAD/Model/Operations/Scheduling/SendDataCleanupOperation.swift new file mode 100644 index 0000000..83392dd --- /dev/null +++ b/RAD/Model/Operations/Scheduling/SendDataCleanupOperation.swift @@ -0,0 +1,94 @@ +// +// SendDataCleanupOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class SendDataCleanupOperation: Operation { + private let context: NSManagedObjectContext + + init(context: NSManagedObjectContext) { + self.context = context + } + + override func execute() { + var operations = createTimezonedDateOperations() + operations += createOperations( + for: Event.self, + withRelation: "dates", + dependentOn: operations.last) + operations += createOperations( + for: MetadataRelation.self, + withRelation: "dates", + dependentOn: operations.last) + operations += createOperations( + for: RadMetadata.self, + withRelation: "metadataRelations", + dependentOn: operations.last) + operations += createOperations( + for: Server.self, + withRelation: "metadataRelations", + dependentOn: operations.last) + let completion = BlockOperation { + self.finish() + } + operations.forEach({ + completion.addDependency($0) + }) + operations.append(completion) + + OperationQueue.background.addOperations( + operations, waitUntilFinished: false) + } + + private func createOperations( + for type: T.Type, + withRelation relation: String, + dependentOn dependentOperation: Foundation.Operation? = nil + ) -> [Foundation.Operation] { + let predicateOperation = CreateEmptyObjectPredicateOperation( + relation: relation) + predicateOperation.addDependency(dependentOperation) + let fetchOperation = FetchOperation(context: context) + predicateOperation.chainOperation(with: fetchOperation) + let deleteOperation = DeleteOperation(context: context) + fetchOperation.chainOperation(with: deleteOperation) + + return [predicateOperation, fetchOperation, deleteOperation] + } + + private func createTimezonedDateOperations( + dependentOn dependentOperation: Foundation.Operation? = nil + ) -> [Foundation.Operation] { + let predicateOperation = CreateEmptyObjectPredicateOperation( + relation: "metadataRelations") + predicateOperation.addDependency(dependentOperation) + let timezonedDatePredicateOperation = + SentTimezonedDatePredicateOperation() + predicateOperation.chainOperation(with: timezonedDatePredicateOperation) + let fetchOperation = FetchOperation(context: context) + timezonedDatePredicateOperation.chainOperation(with: fetchOperation) + let deleteOperation = DeleteOperation(context: context) + fetchOperation.chainOperation(with: deleteOperation) + + return [ + timezonedDatePredicateOperation, + predicateOperation, + fetchOperation, + deleteOperation] + } +} diff --git a/RAD/Model/Operations/Scheduling/SendDataOperation.swift b/RAD/Model/Operations/Scheduling/SendDataOperation.swift new file mode 100644 index 0000000..c712400 --- /dev/null +++ b/RAD/Model/Operations/Scheduling/SendDataOperation.swift @@ -0,0 +1,89 @@ +// +// SendDataOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData +import Reachability + +class SendDataOperation: ChainOperation<[Server], UIBackgroundFetchResult> { + private let configuration: Configuration + private let context: NSManagedObjectContext + private let reachability = Reachability() + + init(configuration: Configuration, context: NSManagedObjectContext) { + self.configuration = configuration + self.context = context + } + + override func execute() { + guard let servers = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + guard let connection = reachability?.connection, connection != .none + else { + Scheduling.last = Date() + finish(with: .failed) + return + } + + let aggregationOperation = AggregateOperation() + let finishOperation = ClosureInputOperation( + closure: { + self.setOutput($0) + Scheduling.last = Date() + self.finish() + }) + aggregationOperation.chainOperation(with: finishOperation) + + var operations: [Foundation.Operation] = [ + aggregationOperation, finishOperation + ] + + servers.forEach({ + let batchOperation = BatchEventsOperation( + configuration: configuration, server: $0, context: context) + let processBatchOperation = ProcessBatchesOperation( + configuration: configuration, context: context) + batchOperation.chainOperation(with: processBatchOperation) + + let processCompletion = + ClosureInputOperation( + closure: { [weak aggregationOperation] in + aggregationOperation?.add($0) + }) + processBatchOperation.chainOperation(with: processCompletion) + aggregationOperation.addDependency(processCompletion) + + operations += [ + batchOperation, processBatchOperation, processCompletion + ] + }) + + let cleanupOperation = SendDataCleanupOperation(context: context) + cleanupOperation.addDependency(aggregationOperation) + let saveOperation = SaveContextOperation(context: context) + saveOperation.addDependency(cleanupOperation) + + operations += [cleanupOperation, saveOperation] + finishOperation.addDependency(saveOperation) + + OperationQueue.background.addOperations( + operations, waitUntilFinished: false) + } +} diff --git a/RAD/Model/Operations/Scheduling/WaitOperation.swift b/RAD/Model/Operations/Scheduling/WaitOperation.swift new file mode 100644 index 0000000..215ffd9 --- /dev/null +++ b/RAD/Model/Operations/Scheduling/WaitOperation.swift @@ -0,0 +1,37 @@ +// +// WaitOperation.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +class WaitOperation: InputOperation { + override func execute() { + guard let interval = input else { + finish(with: InputError.requiredDataNotAvailable) + return + } + + guard interval >= 0 else { + finish() + return + } + + DispatchQueue.background.asyncAfter( + deadline: .now() + interval, execute: { [weak self] in + self?.finish() + }) + } +} diff --git a/RAD/Model/RADDatabaseModel.xcdatamodeld/RADModel.xcdatamodel/contents b/RAD/Model/RADDatabaseModel.xcdatamodeld/RADModel.xcdatamodel/contents new file mode 100644 index 0000000..1791bcb --- /dev/null +++ b/RAD/Model/RADDatabaseModel.xcdatamodeld/RADModel.xcdatamodel/contents @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/RAD/Model/Roundable.swift b/RAD/Model/Roundable.swift new file mode 100644 index 0000000..4dc6674 --- /dev/null +++ b/RAD/Model/Roundable.swift @@ -0,0 +1,22 @@ +// +// Roundable.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +protocol Roundable { + static func lowestUnit() -> R +} diff --git a/RAD/RAD.h b/RAD/RAD.h new file mode 100644 index 0000000..79f060e --- /dev/null +++ b/RAD/RAD.h @@ -0,0 +1,28 @@ +// +// RAD.h +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +#import + +//! Project version number for RAD. +FOUNDATION_EXPORT double RADVersionNumber; + +//! Project version string for RAD. +FOUNDATION_EXPORT const unsigned char RADVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + +#import diff --git a/RAD/Services/Storage.swift b/RAD/Services/Storage.swift new file mode 100644 index 0000000..6a1f896 --- /dev/null +++ b/RAD/Services/Storage.swift @@ -0,0 +1,186 @@ +// +// Storage.swift +// RAD +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import CoreData + +class Storage { + typealias Task = (NSManagedObjectContext) -> Swift.Void + typealias RequestBlock = (_ request: NSFetchRequest) -> Void + + /// A context which is used to create database object from RAD payload. + private (set) var createContext: NSManagedObjectContext? + /// A context which is used to fetch events and send them to server. + /// + /// When certain conditions are met, events will be deleted. + private (set) var deleteContext: NSManagedObjectContext? + + /// A context which is used to sync operations with session ids. + private (set) var sessionContext: NSManagedObjectContext? + + /// The context which is associated with main queue. + var mainQueueContext: NSManagedObjectContext { + return container.viewContext + } + /// A background queue context. + var backgroundQueueContext: NSManagedObjectContext? { + return createContext + } + + static let shared = Storage() + + private static let modelName = "RADDatabaseModel" + private static let databaseName = "RADDatabase" + + private let container: NSPersistentContainer + + private var appTerminationObservation: Any? + + private init?() { + guard let url = Bundle.framework.url( + forResource: Storage.modelName, withExtension: "momd" + ) else { + print("Unable to find CoreData model.") + return nil + } + let model = NSManagedObjectModel(contentsOf: url) + let defaultModel = NSManagedObjectModel() + container = NSPersistentContainer( + name: Storage.databaseName, + managedObjectModel: model ?? defaultModel) + } + + func performBackgroundTask(_ block: @escaping Task) { + container.performBackgroundTask(block) + } + + /// Saves the given context. Must the called on context's queue. + /// + /// - Parameter context: The context to save. + func save(context: NSManagedObjectContext) { + guard context.hasChanges else { return } + + do { + try context.save() + } catch { + print("Unable to save RAD context due to error: \(error).") + } + } + + /// If not loaded already, load persistentStores. + func load() { + guard container.persistentStoreCoordinator.persistentStores.count == 0 + else { return } + + container.loadPersistentStores { [weak self] (_, error) in + if let error = error { + print("Local database was not loaded due to error: \(error).") + } else { + self?.createContexts() + let center = NotificationCenter.default + self?.appTerminationObservation = center.addObserver( + forName: UIApplication.willTerminateNotification, + object: nil, queue: nil, + using: { [weak self] _ in + self?.saveContexts() + }) + } + } + } + + /// Detroy current store and load a new one. + /// The purpose of this function is to simplify the testing. + func refreshDatabase() { + container.persistentStoreCoordinator.performAndWait { + deleteDatabase() + load() + } + } + + // MARK: Private functionality + + private func saveContexts() { + if let createContext = createContext { + createContext.performAndWait { [weak self] in + self?.save(context: createContext) + } + } + + if let deleteContext = deleteContext { + deleteContext.performAndWait { [weak self] in + self?.save(context: deleteContext) + } + } + } + + private func createContexts() { + mainQueueContext.automaticallyMergesChangesFromParent = true + createContext = container.newBackgroundContext() + createContext?.automaticallyMergesChangesFromParent = true + deleteContext = container.newBackgroundContext() + deleteContext?.automaticallyMergesChangesFromParent = true + sessionContext = container.newBackgroundContext() + sessionContext?.automaticallyMergesChangesFromParent = true + } + + private func emptyPredicate(for relation: String) -> NSPredicate { + return NSPredicate( + format: "\(relation).@count == 0", argumentArray: nil) + } + + /// Remove persistent stores and delete the database from url. + /// This approach was chosen over destroyPersistentStore(at:ofType:options:) + /// because it was failing to truncated the database. + private func deleteDatabase() { + do { + try removeAllStores() + try deleteDatabaseFiles() + } catch { + print("Unable to delete database due to error.") + } + } + + /// Removes all stores from coordinator. It should be just 1 SQLite store. + /// + /// - Throws: Error if store removal failed. + private func removeAllStores() throws { + let coordinator = container.persistentStoreCoordinator + for store in coordinator.persistentStores { + try container.persistentStoreCoordinator.remove(store) + } + } + + /// Delete database + /// + /// - Throws: Error if file deletion failed. + private func deleteDatabaseFiles() throws { + let manager = FileManager.default + let databaseUrl = NSPersistentContainer.defaultDirectoryURL() + + let filesInFolder = try manager.contentsOfDirectory( + at: databaseUrl, includingPropertiesForKeys: nil, options: []) + let databaseFiles = filesInFolder.filter({ + $0.lastPathComponent.hasPrefix(container.name) + }) + + try databaseFiles.forEach({ url in + if manager.fileExists(atPath: url.path) { + try manager.removeItem(at: url) + } + }) + } +} diff --git a/RADTests/Extension/Foundation/Bundle+Framework.swift b/RADTests/Extension/Foundation/Bundle+Framework.swift new file mode 100644 index 0000000..7c3fb3f --- /dev/null +++ b/RADTests/Extension/Foundation/Bundle+Framework.swift @@ -0,0 +1,25 @@ +// +// Bundle+Framework.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension Bundle { + /// The bundle associated with Test target. + static let testBundle = Bundle(for: PrivateTestTargetClass.self) +} + +private class PrivateTestTargetClass {} diff --git a/RADTests/Extension/Foundation/DispatchQueue+Queues.swift b/RADTests/Extension/Foundation/DispatchQueue+Queues.swift new file mode 100644 index 0000000..ee784ff --- /dev/null +++ b/RADTests/Extension/Foundation/DispatchQueue+Queues.swift @@ -0,0 +1,29 @@ +// +// DispatchQueue+Queues.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension DispatchQueue { + static let concurrent = { + return DispatchQueue( + label: "RADTests.Operations.concurrent", attributes: [.concurrent]) + }() + + static let serial = { + return DispatchQueue(label: "RADTests.Operations.serial") + }() +} diff --git a/RADTests/Extension/Foundation/Double+Equality.swift b/RADTests/Extension/Foundation/Double+Equality.swift new file mode 100644 index 0000000..d37c3fc --- /dev/null +++ b/RADTests/Extension/Foundation/Double+Equality.swift @@ -0,0 +1,33 @@ +// +// Double+Equality.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension Double { + /// Tests if self is equal with other using a precision. + /// + /// - Parameters: + /// - other: The other double value to test. + /// - precision: The precision which is used for testing. + /// *Default value* is 15 digits and is also the maximum allowed value. + /// - Returns: *true* if self is equal with other, *false* otherwise. + func equals(to other: Double, precision: Int = 15) -> Bool { + let diff = self - other + let argument = max(Double(-precision), -15.0) + return abs(diff) < Double(pow(10.0, argument)) + } +} diff --git a/RADTests/Extension/Foundation/Int+Inversible.swift b/RADTests/Extension/Foundation/Int+Inversible.swift new file mode 100644 index 0000000..f0765f6 --- /dev/null +++ b/RADTests/Extension/Foundation/Int+Inversible.swift @@ -0,0 +1,20 @@ +// +// Int+Inversible.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension Int: Inversable {} diff --git a/RADTests/Extension/Foundation/OperationQueue+Queues.swift b/RADTests/Extension/Foundation/OperationQueue+Queues.swift new file mode 100644 index 0000000..93090fa --- /dev/null +++ b/RADTests/Extension/Foundation/OperationQueue+Queues.swift @@ -0,0 +1,32 @@ +// +// OperationQueue+Queues.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +extension OperationQueue { + static let concurrent: OperationQueue = { + let operationQueue = OperationQueue() + operationQueue.underlyingQueue = DispatchQueue.concurrent + return operationQueue + }() + + static let serial: OperationQueue = { + let operationQueue = OperationQueue() + operationQueue.underlyingQueue = DispatchQueue.serial + return operationQueue + }() +} diff --git a/RADTests/Extension/RAD/Configuration+CustomValues.swift b/RADTests/Extension/RAD/Configuration+CustomValues.swift new file mode 100644 index 0000000..a448020 --- /dev/null +++ b/RADTests/Extension/RAD/Configuration+CustomValues.swift @@ -0,0 +1,28 @@ +// +// Configuration+CustomValues.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import RAD + +extension Configuration { + static let unlimitedTime = Configuration( + submissionTimeInterval: TimeInterval.greatestFiniteMagnitude, + batchSize: UInt.max, + expirationTimeInterval: DateComponents(year: 9_999), + sessionExpirationTimeInterval: TimeInterval.greatestFiniteMagnitude, + requestHeaderFields: [:]) +} diff --git a/RADTests/Extension/XCTest/XCTest+MessageOption.swift b/RADTests/Extension/XCTest/XCTest+MessageOption.swift new file mode 100644 index 0000000..cfa399a --- /dev/null +++ b/RADTests/Extension/XCTest/XCTest+MessageOption.swift @@ -0,0 +1,28 @@ +// +// XCTest+MessageOption.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +/// If message is empty, then default message is returned +/// +/// - Parameters: +/// - message: The message which is tested. +/// - defaultMessage: The default message to be used if message is empty. +/// - Returns: The *message* if not empty, *defaultMessage* otherwise. +func chooseMessage(_ message: String, _ defaultMessage: String) -> String { + return message.isEmpty ? defaultMessage : message +} diff --git a/RADTests/Extension/XCTest/XCTest+OptionalEqual.swift b/RADTests/Extension/XCTest/XCTest+OptionalEqual.swift new file mode 100644 index 0000000..364d46d --- /dev/null +++ b/RADTests/Extension/XCTest/XCTest+OptionalEqual.swift @@ -0,0 +1,89 @@ +// +// XCTest+OptionalEqual.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest + +/// Test if two optional objects are equal. +/// +/// - Parameters: +/// - lhs: The first object. +/// - rhs: The second object. +/// - message: The message to print if test fails. +func XCTOptionalAssertEqual( + _ lhs: T?, + _ rhs: T?, + _ message: String = "", + file: StaticString = #file, + line: UInt = #line +) { + switch (lhs, rhs) { + case (nil, _): + XCTFail(chooseMessage( + message, "Left hand side is not available. Therefore are not equal." + ) + ) + case (_, nil): + XCTFail(chooseMessage( + message, + "Right hand side is not available. Therefore are not equal.")) + case (let left?, let right?): + if left != right { + XCTFail(chooseMessage(message, "Left and right are not equal.")) + } + } +} + +/// Test if first object is equal with inverted second value. +/// +/// - Parameters: +/// - lhs: The object to test. +/// - rhs: The object which is inverted. +/// - message: The message to print if test fails. +func XCTOptionalInversedAssertEqual( + _ lhs: T?, + _ rhs: T?, + _ message: String = "", + file: StaticString = #file, + line: UInt = #line +) { + switch (lhs, rhs) { + case (nil, _): + XCTFail(chooseMessage( + message, + "Left hand side is not available. Therefore are not equal."), + file: file, + line: line + ) + case (_, nil): + XCTFail(chooseMessage( + message, + "Right hand side is not available. Therefore are not equal."), + file: file, + line: line + ) + case (let left?, let right?): + XCTAssertEqual( + left, + -right, + chooseMessage(message, "Left and right are not equal."), + file: file, + line: line + ) + default: + XCTFail("Unexpected case found while comparison optionals.") + } +} diff --git a/RADTests/Info.plist b/RADTests/Info.plist new file mode 100644 index 0000000..6c40a6c --- /dev/null +++ b/RADTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/RADTests/Model/Inversable.swift b/RADTests/Model/Inversable.swift new file mode 100644 index 0000000..23e3d2c --- /dev/null +++ b/RADTests/Model/Inversable.swift @@ -0,0 +1,22 @@ +// +// Inversable.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation + +protocol Inversable { + static prefix func - (_ value: Self) -> Self +} diff --git a/RADTests/Model/KVOExpectation.swift b/RADTests/Model/KVOExpectation.swift new file mode 100644 index 0000000..20b97cd --- /dev/null +++ b/RADTests/Model/KVOExpectation.swift @@ -0,0 +1,42 @@ +// +// KVOExpectation.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import XCTest + +class KVOExpectation: XCTestExpectation { + let object: T + private var observation: Any? + + init( + description expectationDescription: String, + object: T, + keyPath: KeyPath, + expectedValue: Value + ) { + self.object = object + super.init(description: expectationDescription) + observation = object.observe( + keyPath, + options: [.new], + changeHandler: { [weak self] (_, change: NSKeyValueObservedChange) in + if change.newValue == expectedValue { + self?.fulfill() + } + }) + } +} diff --git a/RADTests/Model/MD5Checkable.swift b/RADTests/Model/MD5Checkable.swift new file mode 100644 index 0000000..512a04f --- /dev/null +++ b/RADTests/Model/MD5Checkable.swift @@ -0,0 +1,53 @@ +// +// MD5Checkable.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +import XCTest + +protocol MD5Checkable { + func checkMD5( + for string: String, + expectedMD5: String, + file: StaticString, + line: UInt) +} + +extension MD5Checkable { + /// Create the MD5 hash value for the string + /// and compare it with the expected one. + /// + /// - Parameters: + /// - string: The source string to convert. + /// - expectedMD5: The expected MD5. + func checkMD5( + for string: String, + expectedMD5: String, + file: StaticString = #file, + line: UInt = #line + ) { + if let convertedMD5 = string.md5 { + XCTAssertEqual( + expectedMD5.uppercased(), + convertedMD5.uppercased(), + "'md5' property could not convert the string into MD5.", + file: file, + line: line) + } else { + XCTFail("'md5' property could not convert the string into MD5.") + } + } +} diff --git a/RADTests/Model/MockPlayer.swift b/RADTests/Model/MockPlayer.swift new file mode 100644 index 0000000..3e5cb32 --- /dev/null +++ b/RADTests/Model/MockPlayer.swift @@ -0,0 +1,111 @@ +// +// MockPlayer.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import AVFoundation +@testable import RAD + +class MockPlayer: AVPlayer { + private typealias ChangeHandler = () -> Void + override var timeControlStatus: AVPlayer.TimeControlStatus { + if willStartPlaying { + return .waitingToPlayAtSpecifiedRate + } else { + return timer != nil ? .playing : .paused + } + } + + private var timer: RAD.Timer? + private var interval: TimeInterval { + return TimeInterval.milliseconds(1) + } + private var timePassed: TimeInterval = 0 + private var timescale: CMTimeScale = CMTime.TimeScale.podcast + private var willStartPlaying = false + + override func play() { + guard currentItem != nil else { return } + + willStartPlaying = true + changeValue(for: \MockPlayer.timeControlStatus, changeHandler: nil) + changeValue(for: \MockPlayer.timeControlStatus) { + self.willStartPlaying = false + self.createTimer() + } + } + + override func pause() { + changeValue(for: \MockPlayer.timeControlStatus) { + self.stopTimer() + } + } + + override func replaceCurrentItem(with item: AVPlayerItem?) { + reset() + + super.replaceCurrentItem(with: item) + } + + override func currentTime() -> CMTime { + return CMTime(seconds: timePassed, preferredTimescale: timescale) + } + + override func seek(to time: CMTime) { + timePassed = time.seconds + NotificationCenter.default.post( + name: .AVPlayerItemTimeJumped, object: currentItem) + } + + // MARK: Private functionality + + private func createTimer() { + let interval = self.interval + timer = RAD.Timer.scheduledTimer( + interval: interval, queue: .concurrent, closure: { [weak self] in + guard let strongSelf = self else { return } + strongSelf.timePassed += interval + strongSelf.checkTime() + }) + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + private func reset() { + stopTimer() + timePassed = 0 + } + + private func checkTime() { + guard let currentItem = currentItem else { return } + + if timePassed >= currentItem.asset.duration.seconds { + reset() + NotificationCenter.default.post( + name: .AVPlayerItemDidPlayToEndTime, object: currentItem) + } + } + + private func changeValue( + for keyPath: KeyPath, changeHandler: ChangeHandler? + ) { + willChangeValue(for: keyPath) + changeHandler?() + didChangeValue(for: keyPath) + } +} diff --git a/RADTests/Model/Operations/BooleanOutputOperation.swift b/RADTests/Model/Operations/BooleanOutputOperation.swift new file mode 100644 index 0000000..66ea3c6 --- /dev/null +++ b/RADTests/Model/Operations/BooleanOutputOperation.swift @@ -0,0 +1,25 @@ +// +// BooleanOutputOperation.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +@testable import RAD + +class BooleanOutputOperation: OutputOperation { + override func execute() { + finish(with: true) + } +} diff --git a/RADTests/Model/Operations/ClosureOperation.swift b/RADTests/Model/Operations/ClosureOperation.swift new file mode 100644 index 0000000..0c3b2b7 --- /dev/null +++ b/RADTests/Model/Operations/ClosureOperation.swift @@ -0,0 +1,35 @@ +// +// ClosureOperation.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +@testable import RAD + +/// Calls a closure while executing the operation. +class ClosureOperation: RAD.Operation { + typealias Closure = () -> Void + + let closure: Closure + + init(closure: @escaping Closure) { + self.closure = closure + } + + override func execute() { + closure() + finish() + } +} diff --git a/RADTests/Model/Operations/ErrorOutputOperation.swift b/RADTests/Model/Operations/ErrorOutputOperation.swift new file mode 100644 index 0000000..412e125 --- /dev/null +++ b/RADTests/Model/Operations/ErrorOutputOperation.swift @@ -0,0 +1,25 @@ +// +// ErrorOutputOperation.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +@testable import RAD + +class ErrorOutputOperation: OutputOperation { + override func execute() { + finish(with: OutputError.computationError) + } +} diff --git a/RADTests/Model/Operations/ReverseBooleanOperation.swift b/RADTests/Model/Operations/ReverseBooleanOperation.swift new file mode 100644 index 0000000..d04e183 --- /dev/null +++ b/RADTests/Model/Operations/ReverseBooleanOperation.swift @@ -0,0 +1,29 @@ +// +// ReverseBooleanOperation.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +@testable import RAD + +class ReverseBooleanOperation: ChainOperation { + override func execute() { + if let input = input { + finish(with: !input) + } else { + finish(with: InputError.requiredDataNotAvailable) + } + } +} diff --git a/RADTests/RADTests.swift b/RADTests/RADTests.swift new file mode 100644 index 0000000..8a0088d --- /dev/null +++ b/RADTests/RADTests.swift @@ -0,0 +1,44 @@ +// +// RADTests.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class RADTests: XCTestCase { + + override func setUp() { + super.setUp() + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + super.tearDown() + } + + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testPerformanceExample() { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } +} diff --git a/RADTests/Resources/AudioFiles/100Events2TrackingUrls.mp3 b/RADTests/Resources/AudioFiles/100Events2TrackingUrls.mp3 new file mode 100644 index 0000000..44985fe Binary files /dev/null and b/RADTests/Resources/AudioFiles/100Events2TrackingUrls.mp3 differ diff --git a/RADTests/Resources/AudioFiles/180Events2TrackingUrls.mp3 b/RADTests/Resources/AudioFiles/180Events2TrackingUrls.mp3 new file mode 100644 index 0000000..527d8df Binary files /dev/null and b/RADTests/Resources/AudioFiles/180Events2TrackingUrls.mp3 differ diff --git a/RADTests/Resources/AudioFiles/1_000Events2TrackingUrls.mp3 b/RADTests/Resources/AudioFiles/1_000Events2TrackingUrls.mp3 new file mode 100644 index 0000000..7753390 Binary files /dev/null and b/RADTests/Resources/AudioFiles/1_000Events2TrackingUrls.mp3 differ diff --git a/RADTests/Resources/AudioFiles/240Events2TrackingUrls.mp3 b/RADTests/Resources/AudioFiles/240Events2TrackingUrls.mp3 new file mode 100644 index 0000000..a271278 Binary files /dev/null and b/RADTests/Resources/AudioFiles/240Events2TrackingUrls.mp3 differ diff --git a/RADTests/Resources/AudioFiles/50Events2TrackingUrls.mp3 b/RADTests/Resources/AudioFiles/50Events2TrackingUrls.mp3 new file mode 100644 index 0000000..9b5f3fb Binary files /dev/null and b/RADTests/Resources/AudioFiles/50Events2TrackingUrls.mp3 differ diff --git a/RADTests/Resources/AudioFiles/60Events2TrackingUrls.mp3 b/RADTests/Resources/AudioFiles/60Events2TrackingUrls.mp3 new file mode 100644 index 0000000..70262e7 Binary files /dev/null and b/RADTests/Resources/AudioFiles/60Events2TrackingUrls.mp3 differ diff --git a/RADTests/Resources/AudioFiles/80Events2TrackingUrls.mp3 b/RADTests/Resources/AudioFiles/80Events2TrackingUrls.mp3 new file mode 100644 index 0000000..3b6c63e Binary files /dev/null and b/RADTests/Resources/AudioFiles/80Events2TrackingUrls.mp3 differ diff --git a/RADTests/Resources/AudioFiles/RAD_events.m4a b/RADTests/Resources/AudioFiles/RAD_events.m4a new file mode 100644 index 0000000..80cad66 Binary files /dev/null and b/RADTests/Resources/AudioFiles/RAD_events.m4a differ diff --git a/RADTests/Resources/AudioFiles/RAD_events_properties_not_available.m4a b/RADTests/Resources/AudioFiles/RAD_events_properties_not_available.m4a new file mode 100755 index 0000000..73b4099 Binary files /dev/null and b/RADTests/Resources/AudioFiles/RAD_events_properties_not_available.m4a differ diff --git a/RADTests/Resources/AudioFiles/RAD_extra_properties.m4a b/RADTests/Resources/AudioFiles/RAD_extra_properties.m4a new file mode 100755 index 0000000..9612526 Binary files /dev/null and b/RADTests/Resources/AudioFiles/RAD_extra_properties.m4a differ diff --git a/RADTests/Resources/AudioFiles/RAD_metadata_properties_incorrectly_spelled.m4a b/RADTests/Resources/AudioFiles/RAD_metadata_properties_incorrectly_spelled.m4a new file mode 100755 index 0000000..9e07c78 Binary files /dev/null and b/RADTests/Resources/AudioFiles/RAD_metadata_properties_incorrectly_spelled.m4a differ diff --git a/RADTests/Resources/AudioFiles/RAD_time_not_available.m4a b/RADTests/Resources/AudioFiles/RAD_time_not_available.m4a new file mode 100755 index 0000000..28cee73 Binary files /dev/null and b/RADTests/Resources/AudioFiles/RAD_time_not_available.m4a differ diff --git a/RADTests/Resources/AudioFiles/RAD_time_wrong_format.m4a b/RADTests/Resources/AudioFiles/RAD_time_wrong_format.m4a new file mode 100644 index 0000000..3879697 Binary files /dev/null and b/RADTests/Resources/AudioFiles/RAD_time_wrong_format.m4a differ diff --git a/RADTests/Resources/AudioFiles/no_RAD_medata.m4a b/RADTests/Resources/AudioFiles/no_RAD_medata.m4a new file mode 100755 index 0000000..93f9531 Binary files /dev/null and b/RADTests/Resources/AudioFiles/no_RAD_medata.m4a differ diff --git a/RADTests/Resources/AudioFiles/no_URL_available.m4a b/RADTests/Resources/AudioFiles/no_URL_available.m4a new file mode 100755 index 0000000..e622da5 Binary files /dev/null and b/RADTests/Resources/AudioFiles/no_URL_available.m4a differ diff --git a/RADTests/Resources/AudioFiles/small_audio_file.m4a b/RADTests/Resources/AudioFiles/small_audio_file.m4a new file mode 100644 index 0000000..becdfe6 Binary files /dev/null and b/RADTests/Resources/AudioFiles/small_audio_file.m4a differ diff --git a/RADTests/Resources/RADPayload/1_000_Events.json b/RADTests/Resources/RADPayload/1_000_Events.json new file mode 100644 index 0000000..11ba067 --- /dev/null +++ b/RADTests/Resources/RADPayload/1_000_Events.json @@ -0,0 +1 @@ +{"remoteAudioData":{"trackingUrls":["https:\/\/tracking.npr.org\/tracking_podcasts","https:\/\/remoteaudio.npr.org"],"events":[{"eventTime":"00:00:00.000"},{"eventTime":"00:00:00.120"},{"eventTime":"00:00:00.240"},{"eventTime":"00:00:00.360"},{"eventTime":"00:00:00.480"},{"eventTime":"00:00:00.600"},{"eventTime":"00:00:00.720"},{"eventTime":"00:00:00.840"},{"eventTime":"00:00:00.960"},{"eventTime":"00:00:01.080"},{"eventTime":"00:00:01.200"},{"eventTime":"00:00:01.320"},{"eventTime":"00:00:01.440"},{"eventTime":"00:00:01.560"},{"eventTime":"00:00:01.680"},{"eventTime":"00:00:01.800"},{"eventTime":"00:00:01.920"},{"eventTime":"00:00:02.040"},{"eventTime":"00:00:02.160"},{"eventTime":"00:00:02.280"},{"eventTime":"00:00:02.400"},{"eventTime":"00:00:02.520"},{"eventTime":"00:00:02.640"},{"eventTime":"00:00:02.760"},{"eventTime":"00:00:02.880"},{"eventTime":"00:00:03.000"},{"eventTime":"00:00:03.120"},{"eventTime":"00:00:03.240"},{"eventTime":"00:00:03.360"},{"eventTime":"00:00:03.480"},{"eventTime":"00:00:03.600"},{"eventTime":"00:00:03.720"},{"eventTime":"00:00:03.840"},{"eventTime":"00:00:03.960"},{"eventTime":"00:00:04.080"},{"eventTime":"00:00:04.200"},{"eventTime":"00:00:04.320"},{"eventTime":"00:00:04.440"},{"eventTime":"00:00:04.560"},{"eventTime":"00:00:04.680"},{"eventTime":"00:00:04.800"},{"eventTime":"00:00:04.920"},{"eventTime":"00:00:05.040"},{"eventTime":"00:00:05.160"},{"eventTime":"00:00:05.280"},{"eventTime":"00:00:05.400"},{"eventTime":"00:00:05.520"},{"eventTime":"00:00:05.640"},{"eventTime":"00:00:05.760"},{"eventTime":"00:00:05.880"},{"eventTime":"00:00:06.000"},{"eventTime":"00:00:06.120"},{"eventTime":"00:00:06.240"},{"eventTime":"00:00:06.360"},{"eventTime":"00:00:06.480"},{"eventTime":"00:00:06.600"},{"eventTime":"00:00:06.720"},{"eventTime":"00:00:06.840"},{"eventTime":"00:00:06.960"},{"eventTime":"00:00:07.080"},{"eventTime":"00:00:07.200"},{"eventTime":"00:00:07.320"},{"eventTime":"00:00:07.440"},{"eventTime":"00:00:07.560"},{"eventTime":"00:00:07.680"},{"eventTime":"00:00:07.800"},{"eventTime":"00:00:07.920"},{"eventTime":"00:00:08.040"},{"eventTime":"00:00:08.160"},{"eventTime":"00:00:08.280"},{"eventTime":"00:00:08.400"},{"eventTime":"00:00:08.520"},{"eventTime":"00:00:08.640"},{"eventTime":"00:00:08.760"},{"eventTime":"00:00:08.880"},{"eventTime":"00:00:09.000"},{"eventTime":"00:00:09.120"},{"eventTime":"00:00:09.240"},{"eventTime":"00:00:09.360"},{"eventTime":"00:00:09.480"},{"eventTime":"00:00:09.600"},{"eventTime":"00:00:09.720"},{"eventTime":"00:00:09.840"},{"eventTime":"00:00:09.960"},{"eventTime":"00:00:10.080"},{"eventTime":"00:00:10.200"},{"eventTime":"00:00:10.320"},{"eventTime":"00:00:10.440"},{"eventTime":"00:00:10.560"},{"eventTime":"00:00:10.680"},{"eventTime":"00:00:10.800"},{"eventTime":"00:00:10.920"},{"eventTime":"00:00:11.040"},{"eventTime":"00:00:11.160"},{"eventTime":"00:00:11.280"},{"eventTime":"00:00:11.400"},{"eventTime":"00:00:11.520"},{"eventTime":"00:00:11.640"},{"eventTime":"00:00:11.760"},{"eventTime":"00:00:11.880"},{"eventTime":"00:00:12.000"},{"eventTime":"00:00:12.120"},{"eventTime":"00:00:12.240"},{"eventTime":"00:00:12.360"},{"eventTime":"00:00:12.480"},{"eventTime":"00:00:12.600"},{"eventTime":"00:00:12.720"},{"eventTime":"00:00:12.840"},{"eventTime":"00:00:12.960"},{"eventTime":"00:00:13.080"},{"eventTime":"00:00:13.200"},{"eventTime":"00:00:13.320"},{"eventTime":"00:00:13.440"},{"eventTime":"00:00:13.560"},{"eventTime":"00:00:13.680"},{"eventTime":"00:00:13.800"},{"eventTime":"00:00:13.920"},{"eventTime":"00:00:14.040"},{"eventTime":"00:00:14.160"},{"eventTime":"00:00:14.280"},{"eventTime":"00:00:14.400"},{"eventTime":"00:00:14.520"},{"eventTime":"00:00:14.640"},{"eventTime":"00:00:14.760"},{"eventTime":"00:00:14.880"},{"eventTime":"00:00:15.000"},{"eventTime":"00:00:15.120"},{"eventTime":"00:00:15.240"},{"eventTime":"00:00:15.360"},{"eventTime":"00:00:15.480"},{"eventTime":"00:00:15.600"},{"eventTime":"00:00:15.720"},{"eventTime":"00:00:15.840"},{"eventTime":"00:00:15.960"},{"eventTime":"00:00:16.080"},{"eventTime":"00:00:16.200"},{"eventTime":"00:00:16.320"},{"eventTime":"00:00:16.440"},{"eventTime":"00:00:16.560"},{"eventTime":"00:00:16.680"},{"eventTime":"00:00:16.800"},{"eventTime":"00:00:16.920"},{"eventTime":"00:00:17.040"},{"eventTime":"00:00:17.160"},{"eventTime":"00:00:17.280"},{"eventTime":"00:00:17.400"},{"eventTime":"00:00:17.520"},{"eventTime":"00:00:17.640"},{"eventTime":"00:00:17.760"},{"eventTime":"00:00:17.880"},{"eventTime":"00:00:18.000"},{"eventTime":"00:00:18.120"},{"eventTime":"00:00:18.240"},{"eventTime":"00:00:18.360"},{"eventTime":"00:00:18.480"},{"eventTime":"00:00:18.600"},{"eventTime":"00:00:18.720"},{"eventTime":"00:00:18.840"},{"eventTime":"00:00:18.960"},{"eventTime":"00:00:19.080"},{"eventTime":"00:00:19.200"},{"eventTime":"00:00:19.320"},{"eventTime":"00:00:19.440"},{"eventTime":"00:00:19.560"},{"eventTime":"00:00:19.680"},{"eventTime":"00:00:19.800"},{"eventTime":"00:00:19.920"},{"eventTime":"00:00:20.040"},{"eventTime":"00:00:20.160"},{"eventTime":"00:00:20.280"},{"eventTime":"00:00:20.400"},{"eventTime":"00:00:20.520"},{"eventTime":"00:00:20.640"},{"eventTime":"00:00:20.760"},{"eventTime":"00:00:20.880"},{"eventTime":"00:00:21.000"},{"eventTime":"00:00:21.120"},{"eventTime":"00:00:21.240"},{"eventTime":"00:00:21.360"},{"eventTime":"00:00:21.480"},{"eventTime":"00:00:21.600"},{"eventTime":"00:00:21.720"},{"eventTime":"00:00:21.840"},{"eventTime":"00:00:21.960"},{"eventTime":"00:00:22.080"},{"eventTime":"00:00:22.200"},{"eventTime":"00:00:22.320"},{"eventTime":"00:00:22.440"},{"eventTime":"00:00:22.560"},{"eventTime":"00:00:22.680"},{"eventTime":"00:00:22.800"},{"eventTime":"00:00:22.920"},{"eventTime":"00:00:23.040"},{"eventTime":"00:00:23.160"},{"eventTime":"00:00:23.280"},{"eventTime":"00:00:23.400"},{"eventTime":"00:00:23.520"},{"eventTime":"00:00:23.640"},{"eventTime":"00:00:23.760"},{"eventTime":"00:00:23.880"},{"eventTime":"00:00:24.000"},{"eventTime":"00:00:24.120"},{"eventTime":"00:00:24.240"},{"eventTime":"00:00:24.360"},{"eventTime":"00:00:24.480"},{"eventTime":"00:00:24.600"},{"eventTime":"00:00:24.720"},{"eventTime":"00:00:24.840"},{"eventTime":"00:00:24.960"},{"eventTime":"00:00:25.080"},{"eventTime":"00:00:25.200"},{"eventTime":"00:00:25.320"},{"eventTime":"00:00:25.440"},{"eventTime":"00:00:25.560"},{"eventTime":"00:00:25.680"},{"eventTime":"00:00:25.800"},{"eventTime":"00:00:25.920"},{"eventTime":"00:00:26.040"},{"eventTime":"00:00:26.160"},{"eventTime":"00:00:26.280"},{"eventTime":"00:00:26.400"},{"eventTime":"00:00:26.520"},{"eventTime":"00:00:26.640"},{"eventTime":"00:00:26.760"},{"eventTime":"00:00:26.880"},{"eventTime":"00:00:27.000"},{"eventTime":"00:00:27.120"},{"eventTime":"00:00:27.240"},{"eventTime":"00:00:27.360"},{"eventTime":"00:00:27.480"},{"eventTime":"00:00:27.600"},{"eventTime":"00:00:27.720"},{"eventTime":"00:00:27.840"},{"eventTime":"00:00:27.960"},{"eventTime":"00:00:28.080"},{"eventTime":"00:00:28.200"},{"eventTime":"00:00:28.320"},{"eventTime":"00:00:28.440"},{"eventTime":"00:00:28.560"},{"eventTime":"00:00:28.680"},{"eventTime":"00:00:28.800"},{"eventTime":"00:00:28.920"},{"eventTime":"00:00:29.040"},{"eventTime":"00:00:29.160"},{"eventTime":"00:00:29.280"},{"eventTime":"00:00:29.400"},{"eventTime":"00:00:29.520"},{"eventTime":"00:00:29.640"},{"eventTime":"00:00:29.760"},{"eventTime":"00:00:29.880"},{"eventTime":"00:00:30.000"},{"eventTime":"00:00:30.120"},{"eventTime":"00:00:30.240"},{"eventTime":"00:00:30.360"},{"eventTime":"00:00:30.480"},{"eventTime":"00:00:30.600"},{"eventTime":"00:00:30.720"},{"eventTime":"00:00:30.840"},{"eventTime":"00:00:30.960"},{"eventTime":"00:00:31.080"},{"eventTime":"00:00:31.200"},{"eventTime":"00:00:31.320"},{"eventTime":"00:00:31.440"},{"eventTime":"00:00:31.560"},{"eventTime":"00:00:31.680"},{"eventTime":"00:00:31.800"},{"eventTime":"00:00:31.920"},{"eventTime":"00:00:32.040"},{"eventTime":"00:00:32.160"},{"eventTime":"00:00:32.280"},{"eventTime":"00:00:32.400"},{"eventTime":"00:00:32.520"},{"eventTime":"00:00:32.640"},{"eventTime":"00:00:32.760"},{"eventTime":"00:00:32.880"},{"eventTime":"00:00:33.000"},{"eventTime":"00:00:33.120"},{"eventTime":"00:00:33.240"},{"eventTime":"00:00:33.360"},{"eventTime":"00:00:33.480"},{"eventTime":"00:00:33.600"},{"eventTime":"00:00:33.720"},{"eventTime":"00:00:33.840"},{"eventTime":"00:00:33.960"},{"eventTime":"00:00:34.080"},{"eventTime":"00:00:34.200"},{"eventTime":"00:00:34.320"},{"eventTime":"00:00:34.440"},{"eventTime":"00:00:34.560"},{"eventTime":"00:00:34.680"},{"eventTime":"00:00:34.800"},{"eventTime":"00:00:34.920"},{"eventTime":"00:00:35.040"},{"eventTime":"00:00:35.160"},{"eventTime":"00:00:35.280"},{"eventTime":"00:00:35.400"},{"eventTime":"00:00:35.520"},{"eventTime":"00:00:35.640"},{"eventTime":"00:00:35.760"},{"eventTime":"00:00:35.880"},{"eventTime":"00:00:36.000"},{"eventTime":"00:00:36.120"},{"eventTime":"00:00:36.240"},{"eventTime":"00:00:36.360"},{"eventTime":"00:00:36.480"},{"eventTime":"00:00:36.600"},{"eventTime":"00:00:36.720"},{"eventTime":"00:00:36.840"},{"eventTime":"00:00:36.960"},{"eventTime":"00:00:37.080"},{"eventTime":"00:00:37.200"},{"eventTime":"00:00:37.320"},{"eventTime":"00:00:37.440"},{"eventTime":"00:00:37.560"},{"eventTime":"00:00:37.680"},{"eventTime":"00:00:37.800"},{"eventTime":"00:00:37.920"},{"eventTime":"00:00:38.040"},{"eventTime":"00:00:38.160"},{"eventTime":"00:00:38.280"},{"eventTime":"00:00:38.400"},{"eventTime":"00:00:38.520"},{"eventTime":"00:00:38.640"},{"eventTime":"00:00:38.760"},{"eventTime":"00:00:38.880"},{"eventTime":"00:00:39.000"},{"eventTime":"00:00:39.120"},{"eventTime":"00:00:39.240"},{"eventTime":"00:00:39.360"},{"eventTime":"00:00:39.480"},{"eventTime":"00:00:39.600"},{"eventTime":"00:00:39.720"},{"eventTime":"00:00:39.840"},{"eventTime":"00:00:39.960"},{"eventTime":"00:00:40.080"},{"eventTime":"00:00:40.200"},{"eventTime":"00:00:40.320"},{"eventTime":"00:00:40.440"},{"eventTime":"00:00:40.560"},{"eventTime":"00:00:40.680"},{"eventTime":"00:00:40.800"},{"eventTime":"00:00:40.920"},{"eventTime":"00:00:41.040"},{"eventTime":"00:00:41.160"},{"eventTime":"00:00:41.280"},{"eventTime":"00:00:41.400"},{"eventTime":"00:00:41.520"},{"eventTime":"00:00:41.640"},{"eventTime":"00:00:41.760"},{"eventTime":"00:00:41.880"},{"eventTime":"00:00:42.000"},{"eventTime":"00:00:42.120"},{"eventTime":"00:00:42.240"},{"eventTime":"00:00:42.360"},{"eventTime":"00:00:42.480"},{"eventTime":"00:00:42.600"},{"eventTime":"00:00:42.720"},{"eventTime":"00:00:42.840"},{"eventTime":"00:00:42.960"},{"eventTime":"00:00:43.080"},{"eventTime":"00:00:43.200"},{"eventTime":"00:00:43.320"},{"eventTime":"00:00:43.440"},{"eventTime":"00:00:43.560"},{"eventTime":"00:00:43.680"},{"eventTime":"00:00:43.800"},{"eventTime":"00:00:43.920"},{"eventTime":"00:00:44.040"},{"eventTime":"00:00:44.160"},{"eventTime":"00:00:44.280"},{"eventTime":"00:00:44.400"},{"eventTime":"00:00:44.520"},{"eventTime":"00:00:44.640"},{"eventTime":"00:00:44.760"},{"eventTime":"00:00:44.880"},{"eventTime":"00:00:45.000"},{"eventTime":"00:00:45.120"},{"eventTime":"00:00:45.240"},{"eventTime":"00:00:45.360"},{"eventTime":"00:00:45.480"},{"eventTime":"00:00:45.600"},{"eventTime":"00:00:45.720"},{"eventTime":"00:00:45.840"},{"eventTime":"00:00:45.960"},{"eventTime":"00:00:46.080"},{"eventTime":"00:00:46.200"},{"eventTime":"00:00:46.320"},{"eventTime":"00:00:46.440"},{"eventTime":"00:00:46.560"},{"eventTime":"00:00:46.680"},{"eventTime":"00:00:46.800"},{"eventTime":"00:00:46.920"},{"eventTime":"00:00:47.040"},{"eventTime":"00:00:47.160"},{"eventTime":"00:00:47.280"},{"eventTime":"00:00:47.400"},{"eventTime":"00:00:47.520"},{"eventTime":"00:00:47.640"},{"eventTime":"00:00:47.760"},{"eventTime":"00:00:47.880"},{"eventTime":"00:00:48.000"},{"eventTime":"00:00:48.120"},{"eventTime":"00:00:48.240"},{"eventTime":"00:00:48.360"},{"eventTime":"00:00:48.480"},{"eventTime":"00:00:48.600"},{"eventTime":"00:00:48.720"},{"eventTime":"00:00:48.840"},{"eventTime":"00:00:48.960"},{"eventTime":"00:00:49.080"},{"eventTime":"00:00:49.200"},{"eventTime":"00:00:49.320"},{"eventTime":"00:00:49.440"},{"eventTime":"00:00:49.560"},{"eventTime":"00:00:49.680"},{"eventTime":"00:00:49.800"},{"eventTime":"00:00:49.920"},{"eventTime":"00:00:50.040"},{"eventTime":"00:00:50.160"},{"eventTime":"00:00:50.280"},{"eventTime":"00:00:50.400"},{"eventTime":"00:00:50.520"},{"eventTime":"00:00:50.640"},{"eventTime":"00:00:50.760"},{"eventTime":"00:00:50.880"},{"eventTime":"00:00:51.000"},{"eventTime":"00:00:51.120"},{"eventTime":"00:00:51.240"},{"eventTime":"00:00:51.360"},{"eventTime":"00:00:51.480"},{"eventTime":"00:00:51.600"},{"eventTime":"00:00:51.720"},{"eventTime":"00:00:51.840"},{"eventTime":"00:00:51.960"},{"eventTime":"00:00:52.080"},{"eventTime":"00:00:52.200"},{"eventTime":"00:00:52.320"},{"eventTime":"00:00:52.440"},{"eventTime":"00:00:52.560"},{"eventTime":"00:00:52.680"},{"eventTime":"00:00:52.800"},{"eventTime":"00:00:52.920"},{"eventTime":"00:00:53.040"},{"eventTime":"00:00:53.160"},{"eventTime":"00:00:53.280"},{"eventTime":"00:00:53.400"},{"eventTime":"00:00:53.520"},{"eventTime":"00:00:53.640"},{"eventTime":"00:00:53.760"},{"eventTime":"00:00:53.880"},{"eventTime":"00:00:54.000"},{"eventTime":"00:00:54.120"},{"eventTime":"00:00:54.240"},{"eventTime":"00:00:54.360"},{"eventTime":"00:00:54.480"},{"eventTime":"00:00:54.600"},{"eventTime":"00:00:54.720"},{"eventTime":"00:00:54.840"},{"eventTime":"00:00:54.960"},{"eventTime":"00:00:55.080"},{"eventTime":"00:00:55.200"},{"eventTime":"00:00:55.320"},{"eventTime":"00:00:55.440"},{"eventTime":"00:00:55.560"},{"eventTime":"00:00:55.680"},{"eventTime":"00:00:55.800"},{"eventTime":"00:00:55.920"},{"eventTime":"00:00:56.040"},{"eventTime":"00:00:56.160"},{"eventTime":"00:00:56.280"},{"eventTime":"00:00:56.400"},{"eventTime":"00:00:56.520"},{"eventTime":"00:00:56.640"},{"eventTime":"00:00:56.760"},{"eventTime":"00:00:56.880"},{"eventTime":"00:00:57.000"},{"eventTime":"00:00:57.120"},{"eventTime":"00:00:57.240"},{"eventTime":"00:00:57.360"},{"eventTime":"00:00:57.480"},{"eventTime":"00:00:57.600"},{"eventTime":"00:00:57.720"},{"eventTime":"00:00:57.840"},{"eventTime":"00:00:57.960"},{"eventTime":"00:00:58.080"},{"eventTime":"00:00:58.200"},{"eventTime":"00:00:58.320"},{"eventTime":"00:00:58.440"},{"eventTime":"00:00:58.560"},{"eventTime":"00:00:58.680"},{"eventTime":"00:00:58.800"},{"eventTime":"00:00:58.920"},{"eventTime":"00:00:59.040"},{"eventTime":"00:00:59.160"},{"eventTime":"00:00:59.280"},{"eventTime":"00:00:59.400"},{"eventTime":"00:00:59.520"},{"eventTime":"00:00:59.640"},{"eventTime":"00:00:59.760"},{"eventTime":"00:00:59.880"},{"eventTime":"00:00:60.000"},{"eventTime":"00:01:00.120"},{"eventTime":"00:01:00.240"},{"eventTime":"00:01:00.360"},{"eventTime":"00:01:00.480"},{"eventTime":"00:01:00.600"},{"eventTime":"00:01:00.720"},{"eventTime":"00:01:00.840"},{"eventTime":"00:01:00.960"},{"eventTime":"00:01:01.080"},{"eventTime":"00:01:01.200"},{"eventTime":"00:01:01.320"},{"eventTime":"00:01:01.440"},{"eventTime":"00:01:01.560"},{"eventTime":"00:01:01.680"},{"eventTime":"00:01:01.800"},{"eventTime":"00:01:01.920"},{"eventTime":"00:01:02.040"},{"eventTime":"00:01:02.160"},{"eventTime":"00:01:02.280"},{"eventTime":"00:01:02.400"},{"eventTime":"00:01:02.520"},{"eventTime":"00:01:02.640"},{"eventTime":"00:01:02.760"},{"eventTime":"00:01:02.880"},{"eventTime":"00:01:03.000"},{"eventTime":"00:01:03.120"},{"eventTime":"00:01:03.240"},{"eventTime":"00:01:03.360"},{"eventTime":"00:01:03.480"},{"eventTime":"00:01:03.600"},{"eventTime":"00:01:03.720"},{"eventTime":"00:01:03.840"},{"eventTime":"00:01:03.960"},{"eventTime":"00:01:04.080"},{"eventTime":"00:01:04.200"},{"eventTime":"00:01:04.320"},{"eventTime":"00:01:04.440"},{"eventTime":"00:01:04.560"},{"eventTime":"00:01:04.680"},{"eventTime":"00:01:04.800"},{"eventTime":"00:01:04.920"},{"eventTime":"00:01:05.040"},{"eventTime":"00:01:05.160"},{"eventTime":"00:01:05.280"},{"eventTime":"00:01:05.400"},{"eventTime":"00:01:05.520"},{"eventTime":"00:01:05.640"},{"eventTime":"00:01:05.760"},{"eventTime":"00:01:05.880"},{"eventTime":"00:01:06.000"},{"eventTime":"00:01:06.120"},{"eventTime":"00:01:06.240"},{"eventTime":"00:01:06.360"},{"eventTime":"00:01:06.480"},{"eventTime":"00:01:06.600"},{"eventTime":"00:01:06.720"},{"eventTime":"00:01:06.840"},{"eventTime":"00:01:06.960"},{"eventTime":"00:01:07.080"},{"eventTime":"00:01:07.200"},{"eventTime":"00:01:07.320"},{"eventTime":"00:01:07.440"},{"eventTime":"00:01:07.560"},{"eventTime":"00:01:07.680"},{"eventTime":"00:01:07.800"},{"eventTime":"00:01:07.920"},{"eventTime":"00:01:08.040"},{"eventTime":"00:01:08.160"},{"eventTime":"00:01:08.280"},{"eventTime":"00:01:08.400"},{"eventTime":"00:01:08.520"},{"eventTime":"00:01:08.640"},{"eventTime":"00:01:08.760"},{"eventTime":"00:01:08.880"},{"eventTime":"00:01:09.000"},{"eventTime":"00:01:09.120"},{"eventTime":"00:01:09.240"},{"eventTime":"00:01:09.360"},{"eventTime":"00:01:09.480"},{"eventTime":"00:01:09.600"},{"eventTime":"00:01:09.720"},{"eventTime":"00:01:09.840"},{"eventTime":"00:01:09.960"},{"eventTime":"00:01:10.080"},{"eventTime":"00:01:10.200"},{"eventTime":"00:01:10.320"},{"eventTime":"00:01:10.440"},{"eventTime":"00:01:10.560"},{"eventTime":"00:01:10.680"},{"eventTime":"00:01:10.800"},{"eventTime":"00:01:10.920"},{"eventTime":"00:01:11.040"},{"eventTime":"00:01:11.160"},{"eventTime":"00:01:11.280"},{"eventTime":"00:01:11.400"},{"eventTime":"00:01:11.520"},{"eventTime":"00:01:11.640"},{"eventTime":"00:01:11.760"},{"eventTime":"00:01:11.880"},{"eventTime":"00:01:12.000"},{"eventTime":"00:01:12.120"},{"eventTime":"00:01:12.240"},{"eventTime":"00:01:12.360"},{"eventTime":"00:01:12.480"},{"eventTime":"00:01:12.600"},{"eventTime":"00:01:12.720"},{"eventTime":"00:01:12.840"},{"eventTime":"00:01:12.960"},{"eventTime":"00:01:13.080"},{"eventTime":"00:01:13.200"},{"eventTime":"00:01:13.320"},{"eventTime":"00:01:13.440"},{"eventTime":"00:01:13.560"},{"eventTime":"00:01:13.680"},{"eventTime":"00:01:13.800"},{"eventTime":"00:01:13.920"},{"eventTime":"00:01:14.040"},{"eventTime":"00:01:14.160"},{"eventTime":"00:01:14.280"},{"eventTime":"00:01:14.400"},{"eventTime":"00:01:14.520"},{"eventTime":"00:01:14.640"},{"eventTime":"00:01:14.760"},{"eventTime":"00:01:14.880"},{"eventTime":"00:01:15.000"},{"eventTime":"00:01:15.120"},{"eventTime":"00:01:15.240"},{"eventTime":"00:01:15.360"},{"eventTime":"00:01:15.480"},{"eventTime":"00:01:15.600"},{"eventTime":"00:01:15.720"},{"eventTime":"00:01:15.840"},{"eventTime":"00:01:15.960"},{"eventTime":"00:01:16.080"},{"eventTime":"00:01:16.200"},{"eventTime":"00:01:16.320"},{"eventTime":"00:01:16.440"},{"eventTime":"00:01:16.560"},{"eventTime":"00:01:16.680"},{"eventTime":"00:01:16.800"},{"eventTime":"00:01:16.920"},{"eventTime":"00:01:17.040"},{"eventTime":"00:01:17.160"},{"eventTime":"00:01:17.280"},{"eventTime":"00:01:17.400"},{"eventTime":"00:01:17.520"},{"eventTime":"00:01:17.640"},{"eventTime":"00:01:17.760"},{"eventTime":"00:01:17.880"},{"eventTime":"00:01:18.000"},{"eventTime":"00:01:18.120"},{"eventTime":"00:01:18.240"},{"eventTime":"00:01:18.360"},{"eventTime":"00:01:18.480"},{"eventTime":"00:01:18.600"},{"eventTime":"00:01:18.720"},{"eventTime":"00:01:18.840"},{"eventTime":"00:01:18.960"},{"eventTime":"00:01:19.080"},{"eventTime":"00:01:19.200"},{"eventTime":"00:01:19.320"},{"eventTime":"00:01:19.440"},{"eventTime":"00:01:19.560"},{"eventTime":"00:01:19.680"},{"eventTime":"00:01:19.800"},{"eventTime":"00:01:19.920"},{"eventTime":"00:01:20.040"},{"eventTime":"00:01:20.160"},{"eventTime":"00:01:20.280"},{"eventTime":"00:01:20.400"},{"eventTime":"00:01:20.520"},{"eventTime":"00:01:20.640"},{"eventTime":"00:01:20.760"},{"eventTime":"00:01:20.880"},{"eventTime":"00:01:21.000"},{"eventTime":"00:01:21.120"},{"eventTime":"00:01:21.240"},{"eventTime":"00:01:21.360"},{"eventTime":"00:01:21.480"},{"eventTime":"00:01:21.600"},{"eventTime":"00:01:21.720"},{"eventTime":"00:01:21.840"},{"eventTime":"00:01:21.960"},{"eventTime":"00:01:22.080"},{"eventTime":"00:01:22.200"},{"eventTime":"00:01:22.320"},{"eventTime":"00:01:22.440"},{"eventTime":"00:01:22.560"},{"eventTime":"00:01:22.680"},{"eventTime":"00:01:22.800"},{"eventTime":"00:01:22.920"},{"eventTime":"00:01:23.040"},{"eventTime":"00:01:23.160"},{"eventTime":"00:01:23.280"},{"eventTime":"00:01:23.400"},{"eventTime":"00:01:23.520"},{"eventTime":"00:01:23.640"},{"eventTime":"00:01:23.760"},{"eventTime":"00:01:23.880"},{"eventTime":"00:01:24.000"},{"eventTime":"00:01:24.120"},{"eventTime":"00:01:24.240"},{"eventTime":"00:01:24.360"},{"eventTime":"00:01:24.480"},{"eventTime":"00:01:24.600"},{"eventTime":"00:01:24.720"},{"eventTime":"00:01:24.840"},{"eventTime":"00:01:24.960"},{"eventTime":"00:01:25.080"},{"eventTime":"00:01:25.200"},{"eventTime":"00:01:25.320"},{"eventTime":"00:01:25.440"},{"eventTime":"00:01:25.560"},{"eventTime":"00:01:25.680"},{"eventTime":"00:01:25.800"},{"eventTime":"00:01:25.920"},{"eventTime":"00:01:26.040"},{"eventTime":"00:01:26.160"},{"eventTime":"00:01:26.280"},{"eventTime":"00:01:26.400"},{"eventTime":"00:01:26.520"},{"eventTime":"00:01:26.640"},{"eventTime":"00:01:26.760"},{"eventTime":"00:01:26.880"},{"eventTime":"00:01:27.000"},{"eventTime":"00:01:27.120"},{"eventTime":"00:01:27.240"},{"eventTime":"00:01:27.360"},{"eventTime":"00:01:27.480"},{"eventTime":"00:01:27.600"},{"eventTime":"00:01:27.720"},{"eventTime":"00:01:27.840"},{"eventTime":"00:01:27.960"},{"eventTime":"00:01:28.080"},{"eventTime":"00:01:28.200"},{"eventTime":"00:01:28.320"},{"eventTime":"00:01:28.440"},{"eventTime":"00:01:28.560"},{"eventTime":"00:01:28.680"},{"eventTime":"00:01:28.800"},{"eventTime":"00:01:28.920"},{"eventTime":"00:01:29.040"},{"eventTime":"00:01:29.160"},{"eventTime":"00:01:29.280"},{"eventTime":"00:01:29.400"},{"eventTime":"00:01:29.520"},{"eventTime":"00:01:29.640"},{"eventTime":"00:01:29.760"},{"eventTime":"00:01:29.880"},{"eventTime":"00:01:30.000"},{"eventTime":"00:01:30.120"},{"eventTime":"00:01:30.240"},{"eventTime":"00:01:30.360"},{"eventTime":"00:01:30.480"},{"eventTime":"00:01:30.600"},{"eventTime":"00:01:30.720"},{"eventTime":"00:01:30.840"},{"eventTime":"00:01:30.960"},{"eventTime":"00:01:31.080"},{"eventTime":"00:01:31.200"},{"eventTime":"00:01:31.320"},{"eventTime":"00:01:31.440"},{"eventTime":"00:01:31.560"},{"eventTime":"00:01:31.680"},{"eventTime":"00:01:31.800"},{"eventTime":"00:01:31.920"},{"eventTime":"00:01:32.040"},{"eventTime":"00:01:32.160"},{"eventTime":"00:01:32.280"},{"eventTime":"00:01:32.400"},{"eventTime":"00:01:32.520"},{"eventTime":"00:01:32.640"},{"eventTime":"00:01:32.760"},{"eventTime":"00:01:32.880"},{"eventTime":"00:01:33.000"},{"eventTime":"00:01:33.120"},{"eventTime":"00:01:33.240"},{"eventTime":"00:01:33.360"},{"eventTime":"00:01:33.480"},{"eventTime":"00:01:33.600"},{"eventTime":"00:01:33.720"},{"eventTime":"00:01:33.840"},{"eventTime":"00:01:33.960"},{"eventTime":"00:01:34.080"},{"eventTime":"00:01:34.200"},{"eventTime":"00:01:34.320"},{"eventTime":"00:01:34.440"},{"eventTime":"00:01:34.560"},{"eventTime":"00:01:34.680"},{"eventTime":"00:01:34.800"},{"eventTime":"00:01:34.920"},{"eventTime":"00:01:35.040"},{"eventTime":"00:01:35.160"},{"eventTime":"00:01:35.280"},{"eventTime":"00:01:35.400"},{"eventTime":"00:01:35.520"},{"eventTime":"00:01:35.640"},{"eventTime":"00:01:35.760"},{"eventTime":"00:01:35.880"},{"eventTime":"00:01:36.000"},{"eventTime":"00:01:36.120"},{"eventTime":"00:01:36.240"},{"eventTime":"00:01:36.360"},{"eventTime":"00:01:36.480"},{"eventTime":"00:01:36.600"},{"eventTime":"00:01:36.720"},{"eventTime":"00:01:36.840"},{"eventTime":"00:01:36.960"},{"eventTime":"00:01:37.080"},{"eventTime":"00:01:37.200"},{"eventTime":"00:01:37.320"},{"eventTime":"00:01:37.440"},{"eventTime":"00:01:37.560"},{"eventTime":"00:01:37.680"},{"eventTime":"00:01:37.800"},{"eventTime":"00:01:37.920"},{"eventTime":"00:01:38.040"},{"eventTime":"00:01:38.160"},{"eventTime":"00:01:38.280"},{"eventTime":"00:01:38.400"},{"eventTime":"00:01:38.520"},{"eventTime":"00:01:38.640"},{"eventTime":"00:01:38.760"},{"eventTime":"00:01:38.880"},{"eventTime":"00:01:39.000"},{"eventTime":"00:01:39.120"},{"eventTime":"00:01:39.240"},{"eventTime":"00:01:39.360"},{"eventTime":"00:01:39.480"},{"eventTime":"00:01:39.600"},{"eventTime":"00:01:39.720"},{"eventTime":"00:01:39.840"},{"eventTime":"00:01:39.960"},{"eventTime":"00:01:40.080"},{"eventTime":"00:01:40.200"},{"eventTime":"00:01:40.320"},{"eventTime":"00:01:40.440"},{"eventTime":"00:01:40.560"},{"eventTime":"00:01:40.680"},{"eventTime":"00:01:40.800"},{"eventTime":"00:01:40.920"},{"eventTime":"00:01:41.040"},{"eventTime":"00:01:41.160"},{"eventTime":"00:01:41.280"},{"eventTime":"00:01:41.400"},{"eventTime":"00:01:41.520"},{"eventTime":"00:01:41.640"},{"eventTime":"00:01:41.760"},{"eventTime":"00:01:41.880"},{"eventTime":"00:01:42.000"},{"eventTime":"00:01:42.120"},{"eventTime":"00:01:42.240"},{"eventTime":"00:01:42.360"},{"eventTime":"00:01:42.480"},{"eventTime":"00:01:42.600"},{"eventTime":"00:01:42.720"},{"eventTime":"00:01:42.840"},{"eventTime":"00:01:42.960"},{"eventTime":"00:01:43.080"},{"eventTime":"00:01:43.200"},{"eventTime":"00:01:43.320"},{"eventTime":"00:01:43.440"},{"eventTime":"00:01:43.560"},{"eventTime":"00:01:43.680"},{"eventTime":"00:01:43.800"},{"eventTime":"00:01:43.920"},{"eventTime":"00:01:44.040"},{"eventTime":"00:01:44.160"},{"eventTime":"00:01:44.280"},{"eventTime":"00:01:44.400"},{"eventTime":"00:01:44.520"},{"eventTime":"00:01:44.640"},{"eventTime":"00:01:44.760"},{"eventTime":"00:01:44.880"},{"eventTime":"00:01:45.000"},{"eventTime":"00:01:45.120"},{"eventTime":"00:01:45.240"},{"eventTime":"00:01:45.360"},{"eventTime":"00:01:45.480"},{"eventTime":"00:01:45.600"},{"eventTime":"00:01:45.720"},{"eventTime":"00:01:45.840"},{"eventTime":"00:01:45.960"},{"eventTime":"00:01:46.080"},{"eventTime":"00:01:46.200"},{"eventTime":"00:01:46.320"},{"eventTime":"00:01:46.440"},{"eventTime":"00:01:46.560"},{"eventTime":"00:01:46.680"},{"eventTime":"00:01:46.800"},{"eventTime":"00:01:46.920"},{"eventTime":"00:01:47.040"},{"eventTime":"00:01:47.160"},{"eventTime":"00:01:47.280"},{"eventTime":"00:01:47.400"},{"eventTime":"00:01:47.520"},{"eventTime":"00:01:47.640"},{"eventTime":"00:01:47.760"},{"eventTime":"00:01:47.880"},{"eventTime":"00:01:48.000"},{"eventTime":"00:01:48.120"},{"eventTime":"00:01:48.240"},{"eventTime":"00:01:48.360"},{"eventTime":"00:01:48.480"},{"eventTime":"00:01:48.600"},{"eventTime":"00:01:48.720"},{"eventTime":"00:01:48.840"},{"eventTime":"00:01:48.960"},{"eventTime":"00:01:49.080"},{"eventTime":"00:01:49.200"},{"eventTime":"00:01:49.320"},{"eventTime":"00:01:49.440"},{"eventTime":"00:01:49.560"},{"eventTime":"00:01:49.680"},{"eventTime":"00:01:49.800"},{"eventTime":"00:01:49.920"},{"eventTime":"00:01:50.040"},{"eventTime":"00:01:50.160"},{"eventTime":"00:01:50.280"},{"eventTime":"00:01:50.400"},{"eventTime":"00:01:50.520"},{"eventTime":"00:01:50.640"},{"eventTime":"00:01:50.760"},{"eventTime":"00:01:50.880"},{"eventTime":"00:01:51.000"},{"eventTime":"00:01:51.120"},{"eventTime":"00:01:51.240"},{"eventTime":"00:01:51.360"},{"eventTime":"00:01:51.480"},{"eventTime":"00:01:51.600"},{"eventTime":"00:01:51.720"},{"eventTime":"00:01:51.840"},{"eventTime":"00:01:51.960"},{"eventTime":"00:01:52.080"},{"eventTime":"00:01:52.200"},{"eventTime":"00:01:52.320"},{"eventTime":"00:01:52.440"},{"eventTime":"00:01:52.560"},{"eventTime":"00:01:52.680"},{"eventTime":"00:01:52.800"},{"eventTime":"00:01:52.920"},{"eventTime":"00:01:53.040"},{"eventTime":"00:01:53.160"},{"eventTime":"00:01:53.280"},{"eventTime":"00:01:53.400"},{"eventTime":"00:01:53.520"},{"eventTime":"00:01:53.640"},{"eventTime":"00:01:53.760"},{"eventTime":"00:01:53.880"},{"eventTime":"00:01:54.000"},{"eventTime":"00:01:54.120"},{"eventTime":"00:01:54.240"},{"eventTime":"00:01:54.360"},{"eventTime":"00:01:54.480"},{"eventTime":"00:01:54.600"},{"eventTime":"00:01:54.720"},{"eventTime":"00:01:54.840"},{"eventTime":"00:01:54.960"},{"eventTime":"00:01:55.080"},{"eventTime":"00:01:55.200"},{"eventTime":"00:01:55.320"},{"eventTime":"00:01:55.440"},{"eventTime":"00:01:55.560"},{"eventTime":"00:01:55.680"},{"eventTime":"00:01:55.800"},{"eventTime":"00:01:55.920"},{"eventTime":"00:01:56.040"},{"eventTime":"00:01:56.160"},{"eventTime":"00:01:56.280"},{"eventTime":"00:01:56.400"},{"eventTime":"00:01:56.520"},{"eventTime":"00:01:56.640"},{"eventTime":"00:01:56.760"},{"eventTime":"00:01:56.880"},{"eventTime":"00:01:57.000"},{"eventTime":"00:01:57.120"},{"eventTime":"00:01:57.240"},{"eventTime":"00:01:57.360"},{"eventTime":"00:01:57.480"},{"eventTime":"00:01:57.600"},{"eventTime":"00:01:57.720"},{"eventTime":"00:01:57.840"},{"eventTime":"00:01:57.960"},{"eventTime":"00:01:58.080"},{"eventTime":"00:01:58.200"},{"eventTime":"00:01:58.320"},{"eventTime":"00:01:58.440"},{"eventTime":"00:01:58.560"},{"eventTime":"00:01:58.680"},{"eventTime":"00:01:58.800"},{"eventTime":"00:01:58.920"},{"eventTime":"00:01:59.040"},{"eventTime":"00:01:59.160"},{"eventTime":"00:01:59.280"},{"eventTime":"00:01:59.400"},{"eventTime":"00:01:59.520"},{"eventTime":"00:01:59.640"},{"eventTime":"00:01:59.760"},{"eventTime":"00:01:59.880"},{"eventTime":"00:02:00.000"}]}} \ No newline at end of file diff --git a/RADTests/Resources/RADPayload/MD5_JSON.json b/RADTests/Resources/RADPayload/MD5_JSON.json new file mode 100644 index 0000000..4489e6b --- /dev/null +++ b/RADTests/Resources/RADPayload/MD5_JSON.json @@ -0,0 +1 @@ +{"remoteAudioData":{"events":[{"eventTime":"00:00:00.000"},{"eventTime":"00:01:00.000"},{"eventTime":"00:02:00.000"},{"eventTime":"00:03:00.000"}],"trackingUrls":["https:\/\/tracking.npr.org\/tracking_podcasts"]}} \ No newline at end of file diff --git a/RADTests/Resources/RADPayload/ParsingTests_eventTimeWrongFormat.json b/RADTests/Resources/RADPayload/ParsingTests_eventTimeWrongFormat.json new file mode 100644 index 0000000..419d450 --- /dev/null +++ b/RADTests/Resources/RADPayload/ParsingTests_eventTimeWrongFormat.json @@ -0,0 +1,17 @@ +{ + "remoteAudioData": { + "trackingUrl": "https://tracking.npr.org/tracking_podcasts", + "events": [ + { + "eventTime": "00:00:01,000", + "label": "podcastEvent", + "eventNum": 1, + "creativeId": 0, + "adId": 0, + "adPosition": 0 + } + ], + "episodeId": 522078513, + "podcastId": 510298 + } +} \ No newline at end of file diff --git a/RADTests/Resources/RADPayload/ParsingTests_extraProperties.json b/RADTests/Resources/RADPayload/ParsingTests_extraProperties.json new file mode 100644 index 0000000..134bfec --- /dev/null +++ b/RADTests/Resources/RADPayload/ParsingTests_extraProperties.json @@ -0,0 +1,69 @@ +{ + "remoteAudioData": { + "trackingUrl": "https://tracking.npr.org/tracking_podcasts", + "events": [ + { + "eventTime": "00:00:00.000", + "adId": 0, + "eventNum": 0, + "creativeId": 0, + "label": "podcastDownload", + "adPosition": 0, + "geolocation": { + "longitude": "38.90425", + "latitude": "-77.00906" + } + }, + { + "eventTime": "00:00:00.100", + "adId": 0, + "eventNum": 1, + "creativeId": 0, + "label": "podcastStart", + "adPosition": 0, + "geolocation": { + "longitude": "38.90425", + "latitude": "-77.00906" + } + }, + { + "eventTime": "00:00:00.300", + "adId": 111126, + "eventNum": 2, + "creativeId": 1111130, + "label": "adStart", + "adPosition": 1, + "geolocation": { + "longitude": "38.90425", + "latitude": "-77.00906" + } + }, + { + "eventTime": "00:00:00.600", + "adId": 111126, + "eventNum": 3, + "creativeId": 1111130, + "label": "adEnd", + "adPosition": 1, + "geolocation": { + "longitude": "38.90425", + "latitude": "-77.00906" + } + }, + { + "eventTime": "00:00:01.000", + "adId": 0, + "eventNum": 10, + "creativeId": 0, + "label": "podcast98", + "adPosition": 0, + "geolocation": { + "longitude": "38.90425", + "latitude": "-77.00906" + } + } + ], + "episodeId": 522078513, + "podcastId": 510298 + } +} \ No newline at end of file diff --git a/RADTests/Resources/RADPayload/ParsingTests_mispelledProperty.json b/RADTests/Resources/RADPayload/ParsingTests_mispelledProperty.json new file mode 100644 index 0000000..113a868 --- /dev/null +++ b/RADTests/Resources/RADPayload/ParsingTests_mispelledProperty.json @@ -0,0 +1,6 @@ +{ + "RemoteAudioData": { + "podcastId": 510298, + "episodeId": 522078513 + } +} \ No newline at end of file diff --git a/RADTests/Resources/RADPayload/ParsingTests_missingTimeProperty.json b/RADTests/Resources/RADPayload/ParsingTests_missingTimeProperty.json new file mode 100644 index 0000000..5556808 --- /dev/null +++ b/RADTests/Resources/RADPayload/ParsingTests_missingTimeProperty.json @@ -0,0 +1,16 @@ +{ + "remoteAudioData": { + "trackingUrl": "https://tracking.npr.org/tracking_podcasts", + "events": [ + { + "label": "podcastEvent", + "eventNum": 1, + "creativeId": 0, + "adId": 0, + "adPosition": 0 + } + ], + "episodeId": 522078513, + "podcastId": 510298 + } +} \ No newline at end of file diff --git a/RADTests/Resources/RADPayload/ParsingTests_noURL.json b/RADTests/Resources/RADPayload/ParsingTests_noURL.json new file mode 100644 index 0000000..552f5c2 --- /dev/null +++ b/RADTests/Resources/RADPayload/ParsingTests_noURL.json @@ -0,0 +1,6 @@ +{ + "remoteAudioData": { + "episodeId": 522078513, + "podcastId": 510298 + } +} \ No newline at end of file diff --git a/RADTests/Resources/RADPayload/smallFile_10Events.json b/RADTests/Resources/RADPayload/smallFile_10Events.json new file mode 100644 index 0000000..52ebbba --- /dev/null +++ b/RADTests/Resources/RADPayload/smallFile_10Events.json @@ -0,0 +1 @@ +{"remoteAudioData":{"trackingUrls":["https:\/\/tracking.npr.org\/tracking_podcasts","https:\/\/remoteaudio.npr.org"],"events":[{"eventTime":"00:00:00.000"},{"eventTime":"00:00:00.200"},{"eventTime":"00:00:00.400"},{"eventTime":"00:00:00.600"},{"eventTime":"00:00:00.800"},{"eventTime":"00:00:01.000"},{"eventTime":"00:00:01.200"},{"eventTime":"00:00:01.400"},{"eventTime":"00:00:01.600"},{"eventTime":"00:00:01.800"},{"eventTime":"00:00:02.000"}]}} \ No newline at end of file diff --git a/RADTests/Tests/AnalyticsTestCase.swift b/RADTests/Tests/AnalyticsTestCase.swift new file mode 100644 index 0000000..fc319f3 --- /dev/null +++ b/RADTests/Tests/AnalyticsTestCase.swift @@ -0,0 +1,77 @@ +// +// AnalyticsTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +import CoreData +@testable import RAD + +class AnalyticsTestCase: OperationTestCase { + /// The rule which the test case may use to empty the database. + /// + /// - none: The database remains the same. + /// - once: The database is deleted upon calling first test. + /// - always: The detabase is deleted after each test. + enum DatabaseCleanupRule { + case none, once, always + } + + lazy var player: AVPlayer = { + return playerClass.init() + }() + var playerClass: AVPlayer.Type { + return MockPlayer.self + } + + var configuration: Configuration { + return Configuration.unlimitedTime + } + + var databaseCleanupRule: DatabaseCleanupRule { + return .none + } + + var analytics: Analytics! + + private var databaseWasDeletedOnce = false + private var databaseName: String { + return "RADDatabase" + } + + override func setUp() { + super.setUp() + + applyDatabaseDeleteRule() + + analytics = Analytics(configuration: configuration) + analytics.observePlayer(player) + } + + private func applyDatabaseDeleteRule() { + switch databaseCleanupRule { + case .always: + Storage.shared?.refreshDatabase() + case .once: + if !databaseWasDeletedOnce { + databaseWasDeletedOnce = true + Storage.shared?.refreshDatabase() + } + default: + break + } + } +} diff --git a/RADTests/Tests/CMTimeFormatterTests.swift b/RADTests/Tests/CMTimeFormatterTests.swift new file mode 100644 index 0000000..945671f --- /dev/null +++ b/RADTests/Tests/CMTimeFormatterTests.swift @@ -0,0 +1,106 @@ +// +// CMTimeFormatterTests.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import CoreMedia +@testable import RAD + +class CMTimeFormatterTests: XCTestCase { + func testWellFormattedTimeString() { + let formatter = CMTimeFormatter() + let timeComponents = TimeComponents(minutes: 10, seconds: 3.2) + let time = CMTime(seconds: timeComponents.timeInterval, + preferredTimescale: CMTime.TimeScale.podcast) + let formattedtime = formatter.stringFromTime(time) + XCTAssertNotNil(formattedtime) + XCTAssertEqual(formattedtime, "00:10:03.200", + "Time is not formatted properly.") + } + + func testWellFormattedTimeString_wrongString() { + let formatter = CMTimeFormatter() + let timeComponents = TimeComponents(minutes: 26, seconds: 3.528) + let time = CMTime(seconds: timeComponents.timeInterval, + preferredTimescale: CMTime.TimeScale.podcast) + let formattedtime = formatter.stringFromTime(time) + XCTAssertNotNil(formattedtime) + XCTAssertNotEqual(formattedtime, "00:26:03,528", + "Time is not formatted properly.") + } + + func testWellFormattedTimeString2() { + let formatter = CMTimeFormatter() + let timeComponents = TimeComponents(hours: 3, minutes: 47, + seconds: 34.782) + let time = CMTime(seconds: timeComponents.timeInterval, + preferredTimescale: CMTime.TimeScale.podcast) + let formattedtime = formatter.stringFromTime(time) + XCTAssertNotNil(formattedtime) + XCTAssertEqual(formattedtime, "03:47:34.782", + "Time is not formatted properly.") + } + + func testCMTimeFromString() { + let formatter = CMTimeFormatter() + let time = formatter.timeFromString("00:10:03.200") + XCTAssertNotNil(time) + XCTAssert(time == CMTime(seconds: 603.2, + preferredTimescale: CMTime.TimeScale.podcast), + "Time is not converted from well formatted string.") + } + + func testCMTimeFromString2() { + let formatter = CMTimeFormatter() + let time = formatter.timeFromString("00:26:03.528") + XCTAssertNotNil(time) + XCTAssert(time == CMTime(seconds: 1563.528, + preferredTimescale: CMTime.TimeScale.podcast), + "Time is not converted from well formatted string.") + } + + func testCMTimeFromString3() { + let formatter = CMTimeFormatter() + let time = formatter.timeFromString("03:47:34.782") + XCTAssertNotNil(time) + XCTAssert(time == CMTime(seconds: 13654.782, + preferredTimescale: CMTime.TimeScale.podcast), + "Time is not converted from well formatted string.") + } + + func testIllFormedString() { + let formatter = CMTimeFormatter() + let time = formatter.timeFromString("1::") + XCTAssertNil(time, "Formatter accepted an ill-formed string") + } + + func testValidString() { + let formatter = CMTimeFormatter() + let time = formatter.timeFromString("1:0:0") + XCTAssert(time == CMTime(seconds: 3600.0, + preferredTimescale: CMTime.TimeScale.podcast), + "Formatter is not able format a valid string.") + } + + func testFormatEquality() { + let formatter = CMTimeFormatter() + let time = formatter.timeFromString("0:0:1.000") + let otherTime = formatter.timeFromString("00:00:01.000") + + XCTAssert( + time == otherTime, "Equivalent formats do not create equal CMTime.") + } +} diff --git a/RADTests/Tests/ExtensionTests/DateComponentsTestCase.swift b/RADTests/Tests/ExtensionTests/DateComponentsTestCase.swift new file mode 100644 index 0000000..ccf194a --- /dev/null +++ b/RADTests/Tests/ExtensionTests/DateComponentsTestCase.swift @@ -0,0 +1,56 @@ +// +// DateComponentsTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class DateComponentsTestCase: XCTestCase { + func testMinusOperator() { + let components = DateComponents( + year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6, + nanosecond: 7) + let invertedComponents = -components + XCTOptionalInversedAssertEqual( + components.year, + invertedComponents.year, + "Year components are not equal") + XCTOptionalInversedAssertEqual( + components.month, + invertedComponents.month, + "Month components are not equal.") + XCTOptionalInversedAssertEqual( + components.day, + invertedComponents.day, + "Day components are not equal.") + XCTOptionalInversedAssertEqual( + components.hour, + invertedComponents.hour, + "Hour components are not equal.") + XCTOptionalInversedAssertEqual( + components.minute, + invertedComponents.minute, + "Minute components are not equal") + XCTOptionalInversedAssertEqual( + components.second, + invertedComponents.second, + "Second components are not equal.") + XCTOptionalInversedAssertEqual( + components.nanosecond, + invertedComponents.nanosecond, + "Nanosecond components are not equal.") + } +} diff --git a/RADTests/Tests/ExtensionTests/DictionaryRawRepresentableTestCase.swift b/RADTests/Tests/ExtensionTests/DictionaryRawRepresentableTestCase.swift new file mode 100644 index 0000000..c1a48a9 --- /dev/null +++ b/RADTests/Tests/ExtensionTests/DictionaryRawRepresentableTestCase.swift @@ -0,0 +1,36 @@ +// +// DictionaryRawRepresentableTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +private enum PropertyKey: String { + case key +} + +class DictionaryRawRepresentableTestCase: XCTestCase { + func testGetterSetter() { + let value = "Value for raw representable dictionary." + var dictionary: [String: String] = [:] + dictionary[PropertyKey.key] = value + let getValue = dictionary[PropertyKey.key] + XCTOptionalAssertEqual( + getValue, + value, + "Set value using raw representable keys are not returned back.") + } +} diff --git a/RADTests/Tests/ExtensionTests/DispatchQueueTestCase.swift b/RADTests/Tests/ExtensionTests/DispatchQueueTestCase.swift new file mode 100644 index 0000000..d8ac2e8 --- /dev/null +++ b/RADTests/Tests/ExtensionTests/DispatchQueueTestCase.swift @@ -0,0 +1,52 @@ +// +// DispatchQueueTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class DispatchQueueTestCase: XCTestCase { + private let queue = DispatchQueue( + label: "DispatchQueueTestCase", attributes: [.concurrent]) + + func testSyncExecution() { + let value = "Value" + var valueToSet: String? + queue.execute(block: { + valueToSet = value + }, async: false) + XCTOptionalAssertEqual( + value, + valueToSet, + "Execute function is not running correctly for sync.") + } + + func testAsyncExecution() { + let value = "Value" + var valueToSet: String? + let expectation = XCTestExpectation( + description: "Variable set expectation.") + queue.execute(block: { + valueToSet = value + expectation.fulfill() + }, async: true) + wait(for: [expectation], timeout: TimeInterval.milliseconds(100)) + XCTOptionalAssertEqual( + value, + valueToSet, + "Execute function is not running correctly for async.") + } +} diff --git a/RADTests/Tests/ExtensionTests/DoubleExpectationTestSuite.swift b/RADTests/Tests/ExtensionTests/DoubleExpectationTestSuite.swift new file mode 100644 index 0000000..1eda2e7 --- /dev/null +++ b/RADTests/Tests/ExtensionTests/DoubleExpectationTestSuite.swift @@ -0,0 +1,42 @@ +// +// DoubleExpectationTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class DoubleExpectationTestSuite: XCTestCase { + func testCaseForNonNumericCharacters() { + let string = "abc" + let value = Double.from(string: string) + XCTAssertNil( + value, + "'\(string)' should have not been converted into a double value.") + + } + + func testCaseFor_successValue() { + let string = "123" + let expected: Double = 123 + let converted = Double.from(string: string) + XCTAssertNotNil( + converted, + "'\(string)' should have been converted into a double value.") + XCTAssertTrue( + converted?.isEqual(to: expected) == true, + "Expected value and converted value are not equal.") + } +} diff --git a/RADTests/Tests/ExtensionTests/FoundationOperationDependencyTestCase.swift b/RADTests/Tests/ExtensionTests/FoundationOperationDependencyTestCase.swift new file mode 100644 index 0000000..adbd5d4 --- /dev/null +++ b/RADTests/Tests/ExtensionTests/FoundationOperationDependencyTestCase.swift @@ -0,0 +1,88 @@ +// +// FoundationOperationDependencyTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class FoundationOperationDependencyTestCase: OperationTestCase { + func testForNilOperation() { + let first = 1 + let second = 2 + var objects: [Int] = [] + var initialNilOperation: Foundation.Operation? + + let expectation = XCTestExpectation(description: "Objects are set.") + expectation.expectedFulfillmentCount = 2 + + let other = BlockOperation { + objects.append(first) + expectation.fulfill() + } + other.addDependency(initialNilOperation) + initialNilOperation = BlockOperation { + objects.append(second) + expectation.fulfill() + } + serialQueue.addOperation(other) + if let operation = initialNilOperation { + serialQueue.addOperation(operation) + } + wait(for: [expectation], timeout: TimeInterval.milliseconds(100)) + XCTOptionalAssertEqual( + objects.first, + first, + "First object is not the correct one.") + XCTOptionalAssertEqual( + objects.last, + second, + "Second object is not the correct one.") + } + + func testForSomeOptionalOperation() { + let first = 1 + let second = 2 + var objects: [Int] = [] + + let expectation = XCTestExpectation(description: "Objects are set.") + expectation.expectedFulfillmentCount = 2 + + let initialOperation: Foundation.Operation? = BlockOperation { + objects.append(first) + expectation.fulfill() + } + + let other = BlockOperation { + objects.append(second) + expectation.fulfill() + } + other.addDependency(initialOperation) + + concurrentQueue.addOperation(other) + if let operation = initialOperation { + concurrentQueue.addOperation(operation) + } + wait(for: [expectation], timeout: TimeInterval.milliseconds(200)) + XCTOptionalAssertEqual( + objects.first, + first, + "First object is not the correct one.") + XCTOptionalAssertEqual( + objects.last, + second, + "Second object is not the correct one.") + } +} diff --git a/RADTests/Tests/ExtensionTests/NSStringMD5TestSuite.swift b/RADTests/Tests/ExtensionTests/NSStringMD5TestSuite.swift new file mode 100644 index 0000000..50f55a2 --- /dev/null +++ b/RADTests/Tests/ExtensionTests/NSStringMD5TestSuite.swift @@ -0,0 +1,41 @@ +// +// NSStringMD5TestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class NSStringMD5TestSuite: XCTestCase, MD5Checkable { + func testCaseFor_randomString() { + checkMD5( + for: "This string is used to test the conversion into MD5.", + expectedMD5: "CAC9D9082302D8C988E1DF8144077AA1") + } + + func testCaseFor_json() { + guard let url = Bundle.testBundle.url( + forResource: "MD5_JSON", withExtension: "json") else { + XCTFail("Resource is not available.") + return + } + do { + let json = try String(contentsOf: url) + checkMD5(for: json, expectedMD5: "C8883B8842CCF7AE1A4D98A9BA9F5213") + } catch { + XCTFail("Failed to convert data from resource into string due to error: \(error)") + } + } +} diff --git a/RADTests/Tests/ExtensionTests/RangeReplaceableCollectionTestSuite.swift b/RADTests/Tests/ExtensionTests/RangeReplaceableCollectionTestSuite.swift new file mode 100644 index 0000000..168a242 --- /dev/null +++ b/RADTests/Tests/ExtensionTests/RangeReplaceableCollectionTestSuite.swift @@ -0,0 +1,63 @@ +// +// RangeReplaceableCollectionTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class RangeReplaceableCollectionTestSuite: XCTestCase { + func testCaseFor_appendOperator_oneElement() { + let value: String = "value" + let collection = ["other element"] + let new = collection + value + XCTOptionalAssertEqual( + new.last, + value, + "Append operator '+' does not perform changed correcltly.") + } + + func testCaseFor_appendOperator_aCollection() { + let value: String = "value" + let collection = ["other element"] + let new = collection + [value] + XCTOptionalAssertEqual( + new.last, + value, + "Append operator '+' does not perform changed correcltly.") + } + + func testCaseFor_mutatingAppendOperator_oneElement() { + let value: String = "value" + var collection = ["other element"] + collection += value + XCTOptionalAssertEqual( + collection.last, + value, + "Mutating append operator '+=' does not perform changed correcltly." + ) + } + + func testCaseFor_mutatingAppendOperator_aCollection() { + let value: String = "value" + var collection = ["other element"] + collection += [value] + XCTOptionalAssertEqual( + collection.last, + value, + "Mutating append operator '+=' does not perform changed correcltly." + ) + } +} diff --git a/RADTests/Tests/ExtensionTests/SwiftMathFunctionTestSuite.swift b/RADTests/Tests/ExtensionTests/SwiftMathFunctionTestSuite.swift new file mode 100644 index 0000000..a481975 --- /dev/null +++ b/RADTests/Tests/ExtensionTests/SwiftMathFunctionTestSuite.swift @@ -0,0 +1,85 @@ +// +// SwiftMathFunctionTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class SwiftMathFunctionTestSuite: XCTestCase { + func testCaseFor_floorFunction() { + let measurement = Measurement(value: 1.2, unit: .meters) + let expected = Measurement(value: 1.0, unit: .meters) + let floorValue = floor(measurement) + XCTAssertTrue( + floorValue.value.isEqual(to: expected.value), + "Floor does not compute the value correctly.") + } + + func testCaseFor_roundFunction_byPerformingFloor() { + let measurement = Measurement(value: 1.2, unit: .meters) + let expected = Measurement(value: 1.0, unit: .meters) + let roundValue = round(measurement) + XCTAssertTrue( + roundValue.value.isEqual(to: expected.value), + "Round does not compute the value correctly.") + } + + func testCaseFor_roundFunction_byPerformingCeil() { + let measurement = Measurement(value: 1.78, unit: .meters) + let expected = Measurement(value: 2.0, unit: .meters) + let roundValue = round(measurement) + XCTAssertTrue( + roundValue.value.isEqual(to: expected.value), + "Round does not compute the value correctly.") + } + +// func testCaseFor_ceilFunction() { +// let measurement = Measurement(value: 1.78, unit: .meters) +// let expected = Measurement(value: 2.0, unit: .meters) +// let ceilValue = ceil(measurement) +// XCTAssertTrue( +// ceilValue.value.isEqual(to: expected.value), +// "Ceil does not compute the value correctly.") +// } + + func testCaseFor_roundingMeasurement_usingRound() { + let centimeters = Measurement( + value: 190, unit: .centimeters) + let meters = centimeters.converted(to: .meters) + let expected = Measurement(value: 2.0, unit: .meters) + let rounded = roundingMeasurement(meters) + XCTAssertTrue( + rounded.value.isEqual(to: expected.value), + "Rounding measurement does not compute value correctly.") + } + + func testCaseFor_roundingMeasurement_usingFloor() { + let decameters = Measurement( + value: 1.7, unit: .decameters) + let expected = Measurement(value: 1.0, unit: .decameters) + let rounded = roundingMeasurement(decameters) + XCTAssertTrue( + rounded.value.isEqual(to: expected.value), + "Rounding measurement does not compute value correctly.") + } +} + +extension UnitLength: Roundable { + public static func lowestUnit() -> R where R: Roundable { + // swiftlint:disable next force_cast + return UnitLength.meters as! R + } +} diff --git a/RADTests/Tests/ExtensionTests/TimeIntervalTestSuite.swift b/RADTests/Tests/ExtensionTests/TimeIntervalTestSuite.swift new file mode 100644 index 0000000..f9e89c2 --- /dev/null +++ b/RADTests/Tests/ExtensionTests/TimeIntervalTestSuite.swift @@ -0,0 +1,74 @@ +// +// TimeIntervalTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest + +class TimeIntervalTestSuite: XCTestCase { + func testCaseFor_milliseconds() { + let expected = 0.015 + let computed = TimeInterval.milliseconds(15) + XCTAssertEqual( + expected, + computed, + "TimeInterval's milliseconds is not computing the value correcly.") + } + + func testCaseFor_floatingPointMilliseconds() { + let expected = 0.0005 + let computed = TimeInterval.milliseconds(0.5) + XCTAssertEqual( + expected, + computed, + "TimeInterval's milliseconds is not computing the value correcly.") + } + + func testCaseFor_minutes() { + let expected: Double = 180 // 3 minutes + let computed = TimeInterval.minutes(3) + XCTAssertEqual( + expected, + computed, + "TimeInterval's minutes is not computing the value correcly.") + } + + func testCaseFor_floatingPointMinutes() { + let expected: Double = 210 // 3 minutes and 30 seconds + let computed = TimeInterval.minutes(3.5) + XCTAssertEqual( + expected, + computed, + "TimeInterval's minutes is not computing the value correcly.") + } + + func testCaseFor_hours() { + let expected: Double = 7_200 + let computed = TimeInterval.hours(2) + XCTAssertEqual( + expected, + computed, + "TimeInterval's hours is not computing the value correcly.") + } + + func testCaseFor_floatingPointHours() { + let expected: Double = 9_000 + let computed = TimeInterval.hours(2.5) + XCTAssertEqual( + expected, + computed, + "TimeInterval's hours is not computing the value correcly.") + } +} diff --git a/RADTests/Tests/ExtensionTests/UrlRequestTestCase.swift b/RADTests/Tests/ExtensionTests/UrlRequestTestCase.swift new file mode 100644 index 0000000..f391826 --- /dev/null +++ b/RADTests/Tests/ExtensionTests/UrlRequestTestCase.swift @@ -0,0 +1,34 @@ +// +// UrlRequestTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class UrlRequestTestCase: XCTestCase { + func testCaseFor_GetterSetter_method() { + guard let url = URL(string: "https://www.apple.com") else { + XCTFail("Invalid url") + return + } + var urlRequest = URLRequest(url: url) + urlRequest.method = .get + XCTOptionalAssertEqual( + urlRequest.method, + HTTPMethod.get, + "HTTP method is not set correctly on url request.") + } +} diff --git a/RADTests/Tests/GeneratedEventsTests.swift b/RADTests/Tests/GeneratedEventsTests.swift new file mode 100644 index 0000000..3f676f8 --- /dev/null +++ b/RADTests/Tests/GeneratedEventsTests.swift @@ -0,0 +1,26 @@ +// +// GeneratedEventsTests.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +@testable import RAD + +class GeneratedEventsTests: AnalyticsTestCase { + override var playerClass: AVPlayer.Type { + return Player.self + } +} diff --git a/RADTests/Tests/ModelTests/DebuggerTests/AnalyticsDebuggerExtractPayloadTestCase.swift b/RADTests/Tests/ModelTests/DebuggerTests/AnalyticsDebuggerExtractPayloadTestCase.swift new file mode 100644 index 0000000..ea34a21 --- /dev/null +++ b/RADTests/Tests/ModelTests/DebuggerTests/AnalyticsDebuggerExtractPayloadTestCase.swift @@ -0,0 +1,123 @@ +// +// AnalyticsDebuggerExtractPayloadTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +@testable import RAD + +class AnalyticsDebuggerExtractPayloadTestCase: AnalyticsTestCase, MD5Checkable { + func testPayloadExtraction() { + guard let url = Bundle.testBundle.url( + forResource: "50Events2TrackingUrls", withExtension: "mp3" + ) else { + XCTFail("Resource is not available.") + return + } + let item = AVPlayerItem(url: url) + + let payloadExpectation = self.expectation( + description: "Payload check.") + analytics.debugger.extractRADPayload( + from: item.asset, + completion: { payload in + self.checkMD5( + for: payload, + expectedMD5: "4F2249C973480A1968E61F523A2C2F01") + payloadExpectation.fulfill() + }) + wait(for: [payloadExpectation], timeout: TimeInterval.seconds(30)) + } + + func testRangeCreation() { + guard let url = Bundle.testBundle.url( + forResource: "1_000Events2TrackingUrls", withExtension: "mp3" + ) else { + XCTFail("Resource is not available.") + return + } + let expectation = RangeExpectation( + description: "Time range was created.") + + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + + DispatchQueue.main.asyncAfter( + deadline: .now() + .seconds(1), execute: { + self.analytics.debugger.addListeningObserver(expectation) + self.player.play() + }) + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(20), execute: { + self.player.pause() + }) + + wait(for: [expectation], timeout: TimeInterval.minutes(1)) + } + + func testListeningRemoval() { + guard let url = Bundle.testBundle.url( + forResource: "1_000Events2TrackingUrls", withExtension: "mp3" + ) else { + XCTFail("Resource is not available.") + return + } + let expectation = RangeExpectation( + description: "Time range was created.") + + expectation.assertForOverFulfill = true + + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + + DispatchQueue.main.asyncAfter( + deadline: .now() + .seconds(1), execute: { + self.analytics.debugger.addListeningObserver(expectation) + self.player.play() + }) + + let waitExpectation = self.expectation(description: "Second pause.") + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(5), execute: { + self.player.pause() + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(5), + execute: { + self.analytics.debugger.removeListeningObserver( + expectation) + self.player.play() + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(5), + execute: { + self.player.pause() + waitExpectation.fulfill() + }) + }) + }) + + wait( + for: [expectation, waitExpectation], + timeout: TimeInterval.minutes(1)) + } +} + +private class RangeExpectation: XCTestExpectation, ListeningObserver { + func didGenerateListeningRanges(_ ranges: [Object]) { + fulfill() + } +} diff --git a/RADTests/Tests/ModelTests/DebuggerTests/AnalyticsTestSuite.swift b/RADTests/Tests/ModelTests/DebuggerTests/AnalyticsTestSuite.swift new file mode 100644 index 0000000..9e3f562 --- /dev/null +++ b/RADTests/Tests/ModelTests/DebuggerTests/AnalyticsTestSuite.swift @@ -0,0 +1,56 @@ +// +// AnalyticsTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +@testable import RAD + +class AnalyticsTestSuite: AnalyticsTestCase { + func testBackgroundFetch() { + analytics.stopSendingData() + analytics.startSendingData() + + guard let url = Bundle.testBundle.url( + forResource: "1_000Events2TrackingUrls", + withExtension: "mp3" + ) else { + XCTFail("Resource is not available.") + return + } + + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + + let expectation = self.expectation( + description: "Wait for background fetch.") + + self.player.play() + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(5), execute: { + self.player.replaceCurrentItem(with: nil) + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(2), execute: { + self.analytics.performBackgroundFetch { _ in + expectation.fulfill() + } + }) + }) + + wait(for: [expectation], timeout: TimeInterval.seconds(20)) + } +} diff --git a/RADTests/Tests/ModelTests/EntitiesTests/ItemDidPlayToEndExpectation.swift b/RADTests/Tests/ModelTests/EntitiesTests/ItemDidPlayToEndExpectation.swift new file mode 100644 index 0000000..e548861 --- /dev/null +++ b/RADTests/Tests/ModelTests/EntitiesTests/ItemDidPlayToEndExpectation.swift @@ -0,0 +1,40 @@ +// +// ItemDidPlayToEndExpectation.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +@testable import RAD + +class ItemDidPlayToEndExpectation: XCTestExpectation { + private var playerReachedEndOfFileObservation: Any? + + init( + item: AVPlayerItem, + description expectationDescription: String + ) { + super.init(description: expectationDescription) + + playerReachedEndOfFileObservation = + NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: item, + queue: OperationQueue.main, + using: { [weak self] _ in + self?.fulfill() + }) + } +} diff --git a/RADTests/Tests/ModelTests/EntitiesTests/PlayerTestCase.swift b/RADTests/Tests/ModelTests/EntitiesTests/PlayerTestCase.swift new file mode 100644 index 0000000..2766a59 --- /dev/null +++ b/RADTests/Tests/ModelTests/EntitiesTests/PlayerTestCase.swift @@ -0,0 +1,55 @@ +// +// PlayerTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +@testable import RAD + +class PlayerTestCase: XCTestCase { + var resourceName: String { + return "50Events2TrackingUrls" + } + + var resourceExtension: String { + return "mp3" + } + + var item: AVPlayerItem? + + private (set) var timeRangeController: TimeRangeController? + private (set) var player: MockPlayer! + + override func setUp() { + player = MockPlayer() + + guard let url = Bundle.testBundle.url( + forResource: resourceName, withExtension: resourceExtension + ) else { + return + } + + item = AVPlayerItem(url: url) + + player.replaceCurrentItem(with: item) + + timeRangeController = TimeRangeController(player: player) + } + + override func tearDown() { + item = nil + } +} diff --git a/RADTests/Tests/ModelTests/EntitiesTests/RangeCreationExpectationBuilder.swift b/RADTests/Tests/ModelTests/EntitiesTests/RangeCreationExpectationBuilder.swift new file mode 100644 index 0000000..5fd1aeb --- /dev/null +++ b/RADTests/Tests/ModelTests/EntitiesTests/RangeCreationExpectationBuilder.swift @@ -0,0 +1,32 @@ +// +// RangeCreationExpectationBuilder.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +protocol RangeCreationExpectationBuilder { + func buildRangeCreationExpectation() -> XCTestExpectation +} + +extension RangeCreationExpectationBuilder where Self: PlayerTestCase { + func buildRangeCreationExpectation() -> XCTestExpectation { + let rangeCreationExpectation = TimeRangeCreationExpectation( + description: "Time Controller did create range.") + timeRangeController?.delegate = rangeCreationExpectation + return rangeCreationExpectation + } +} diff --git a/RADTests/Tests/ModelTests/EntitiesTests/TimeRangeControllerEndOfFileTestCase.swift b/RADTests/Tests/ModelTests/EntitiesTests/TimeRangeControllerEndOfFileTestCase.swift new file mode 100644 index 0000000..f48ab81 --- /dev/null +++ b/RADTests/Tests/ModelTests/EntitiesTests/TimeRangeControllerEndOfFileTestCase.swift @@ -0,0 +1,48 @@ +// +// TimeRangeControllerEndOfFileTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest + +class TimeRangeControllerEndOfFileTestCase: PlayerTestCase, +RangeCreationExpectationBuilder { + override var resourceName: String { + return "small_audio_file" + } + override var resourceExtension: String { + return "m4a" + } + + func testCaseFor_rangeCreation_reachingEndOfFile() { + guard let item = item else { + XCTFail("Current item is not set on player.") + return + } + let playToEndExpectation = ItemDidPlayToEndExpectation( + item: item, description: "Item did play to end.") + let rangeCreationExpectation = buildRangeCreationExpectation() + + let duration = item.asset.duration.seconds + let timeout = duration + TimeInterval.seconds(10) + + player.play() + + wait( + for: [playToEndExpectation, rangeCreationExpectation], + timeout: timeout + ) + } +} diff --git a/RADTests/Tests/ModelTests/EntitiesTests/TimeRangeControllerTestSuite.swift b/RADTests/Tests/ModelTests/EntitiesTests/TimeRangeControllerTestSuite.swift new file mode 100644 index 0000000..bc08817 --- /dev/null +++ b/RADTests/Tests/ModelTests/EntitiesTests/TimeRangeControllerTestSuite.swift @@ -0,0 +1,74 @@ +// +// TimeRangeControllerTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +@testable import RAD + +class TimeRangeControllerTestSuite: PlayerTestCase, +RangeCreationExpectationBuilder { + func testCaseFor_rangeCreation_onPause() { + XCTAssertNotNil( + player.currentItem, "Current item is not set on player.") + + player.play() + let expectation = self.expectation( + description: "Player did pause.") + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(10), + execute: { + self.player.pause() + expectation.fulfill() + }) + + let rangeCreationExpectation = buildRangeCreationExpectation() + + wait( + for: [expectation, rangeCreationExpectation], + timeout: TimeInterval.seconds(12) + ) + } + + func testCaseFor_rangeCreation_onSeek() { + player.play() + + let seekExpectation = self.expectation( + description: "Player did seek.") + + let rangeCreationExpectation = buildRangeCreationExpectation() + rangeCreationExpectation.expectedFulfillmentCount = 2 + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(2), + execute: { + let time = CMTime( + seconds: 10, + preferredTimescale: CMTime.TimeScale.podcast) + self.player.seek(to: time) + seekExpectation.fulfill() + }) + + DispatchQueue.background.asyncAfter(deadline: .now() + 5.0) { + self.player.pause() + } + + wait( + for: [seekExpectation, rangeCreationExpectation], + timeout: TimeInterval.seconds(10) + ) + } +} diff --git a/RADTests/Tests/ModelTests/EntitiesTests/TimeRangeCreationExpectation.swift b/RADTests/Tests/ModelTests/EntitiesTests/TimeRangeCreationExpectation.swift new file mode 100644 index 0000000..9cee952 --- /dev/null +++ b/RADTests/Tests/ModelTests/EntitiesTests/TimeRangeCreationExpectation.swift @@ -0,0 +1,36 @@ +// +// TimeRangeCreationExpectation.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +@testable import RAD + +class TimeRangeCreationExpectation: XCTestExpectation, +TimeRangeControllerDelegate { + func timeRangeController( + _ timeRangeController: TimeRangeController, + didCreateTimeRange timeRange: TimeRange, + synced: Bool + ) { + fulfill() + } + + func timeRangeControllerDidFinishCreatingRanges( + _ timeRangeController: TimeRangeController, synced: Bool + ) { + } +} diff --git a/RADTests/Tests/ModelTests/EntitiesTests/WeakReferenceContainerTestCase.swift b/RADTests/Tests/ModelTests/EntitiesTests/WeakReferenceContainerTestCase.swift new file mode 100644 index 0000000..6c27f60 --- /dev/null +++ b/RADTests/Tests/ModelTests/EntitiesTests/WeakReferenceContainerTestCase.swift @@ -0,0 +1,49 @@ +// +// WeakReferenceContainerTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class WeakReferenceContainerTestCase: XCTestCase { + func testRemoval() { + var container = WeakReferenceContainer() + let reference = ReferenceHelper(flag: false) + container.append(reference) + container.forEach { $0?.test() } + XCTAssertTrue(reference.flag, "Container did not call helper.") + + container.remove(reference) + container.forEach { $0?.test() } + XCTAssertTrue(reference.flag, "Container did not call helper.") + } +} + +private protocol Helper { + func test() +} + +private class ReferenceHelper: Helper { + private (set) var flag: Bool + + init(flag: Bool) { + self.flag = flag + } + + func test() { + flag = !flag + } +} diff --git a/RADTests/Tests/ModelTests/EntitiesTests/WeakReferenceTestCase.swift b/RADTests/Tests/ModelTests/EntitiesTests/WeakReferenceTestCase.swift new file mode 100644 index 0000000..ae93644 --- /dev/null +++ b/RADTests/Tests/ModelTests/EntitiesTests/WeakReferenceTestCase.swift @@ -0,0 +1,28 @@ +// +// WeakReferenceTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class WeakReferenceTestCase: XCTestCase { + func testEquality() { + let oneReference = WeakReference(value: self) + let otherReference = WeakReference(value: self) + XCTAssertEqual( + oneReference, otherReference, "The references should be equal.") + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/AsyncClosureInputOperationTestSuite.swift b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/AsyncClosureInputOperationTestSuite.swift new file mode 100644 index 0000000..0b86be0 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/AsyncClosureInputOperationTestSuite.swift @@ -0,0 +1,52 @@ +// +// AsyncClosureInputOperationTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class AsyncClosureInputOperationTestSuite: OperationTestCase { + func testClosureIsCalled() { + var flag = false + let expectation = self.expectation( + description: "Async operation is not calling the closure.") + let operation = AsyncClosureInputOperation { (value, completion) in + flag = value + expectation.fulfill() + completion() + } + operation.input = true + concurrentQueue.addOperation(operation) + wait(for: [expectation], timeout: TimeInterval.milliseconds(100)) + XCTAssertTrue(flag, "Closure was not called") + } + + func test_inputNotAvailable() { + var flag = true + let expectation = self.expectation(description: "Operation did finish.") + let operation = AsyncClosureInputOperation { (_, completion) in + flag = false + completion() + } + operation.completionBlock = { + expectation.fulfill() + } + operation.updateReady(true) + concurrentQueue.addOperation(operation) + wait(for: [expectation], timeout: TimeInterval.seconds(1)) + XCTAssertTrue(flag, "Closure operation should not call the closure.") + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/ChainOperationTestSuite.swift b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/ChainOperationTestSuite.swift new file mode 100644 index 0000000..aa8a087 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/ChainOperationTestSuite.swift @@ -0,0 +1,58 @@ +// +// ChainOperationTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class ChainOperationTestSuite: OperationTestCase { + func testCaseFor_finishWithOutput() { + let outputOperation = BooleanOutputOperation() + let chainOperation = ReverseBooleanOperation() + outputOperation.chainOperation(with: chainOperation) + let expectation = self.expectation( + description: "Chain operation should have finished with object.") + let input = ClosureInputOperation { _ in + expectation.fulfill() + } + chainOperation.chainOperation(with: input) + serialQueue.addOperations( + [outputOperation, chainOperation, input], waitUntilFinished: false) + wait(for: [expectation], timeout: TimeInterval.milliseconds(100)) + } + + func testCaseFor_cancellingOperations() { + let outputOperation = BooleanOutputOperation() + let chainOperation = ReverseBooleanOperation() + outputOperation.chainOperation(with: chainOperation) + let inputOperation = ClosureInputOperation { _ in } + chainOperation.chainOperation(with: inputOperation) + outputOperation.cancel() + + serialQueue.addOperations( + [outputOperation, chainOperation, inputOperation], + waitUntilFinished: true) + XCTAssertTrue( + outputOperation.isCancelled, + "Input operation should have been cancelled.") + XCTAssertTrue( + chainOperation.isCancelled, + "Input operation should have been cancelled.") + XCTAssertTrue( + inputOperation.isCancelled, + "Input operation should have been cancelled.") + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/ClosureInputOperationTestSuite.swift b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/ClosureInputOperationTestSuite.swift new file mode 100644 index 0000000..adb87ce --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/ClosureInputOperationTestSuite.swift @@ -0,0 +1,50 @@ +// +// ClosureInputOperationTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class ClosureInputOperationTestSuite: OperationTestCase { + func testClosureIsCalled() { + var flag = false + let expectation = self.expectation( + description: "Closure operation is not calling the closure.") + let operation = ClosureInputOperation { value in + flag = value + expectation.fulfill() + } + operation.input = true + concurrentQueue.addOperation(operation) + wait(for: [expectation], timeout: TimeInterval.milliseconds(100)) + XCTAssertTrue(flag, "Closure was not called") + } + + func testInput_notAvailable() { + var flag = true + let expectation = self.expectation(description: "Operation did finish.") + let operation = ClosureInputOperation { _ in + flag = false + } + operation.completionBlock = { + expectation.fulfill() + } + operation.updateReady(true) + concurrentQueue.addOperation(operation) + wait(for: [expectation], timeout: TimeInterval.seconds(1)) + XCTAssertTrue(flag, "Closure operation should not call the closure.") + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/InputOperationTestSuite.swift b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/InputOperationTestSuite.swift new file mode 100644 index 0000000..a809a07 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/InputOperationTestSuite.swift @@ -0,0 +1,34 @@ +// +// InputOperationTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class InputOperationTestSuite: XCTestCase { + func testReadyContructor() { + let inputOperation = InputOperation() + XCTAssertFalse( + inputOperation.isReady, "'isReady' should be false by default.") + } + + func testInputProperty() { + let inputOperation = InputOperation() + inputOperation.input = true + XCTAssertTrue( + inputOperation.isReady, "'isReady' should have become true.") + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsAsyncTestCase.swift b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsAsyncTestCase.swift new file mode 100644 index 0000000..07936e9 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsAsyncTestCase.swift @@ -0,0 +1,27 @@ +// +// OperationIsAsyncTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class OperationIsAsyncTestCase: XCTestCase { + func testIsAsync() { + let operation = RAD.Operation() + XCTAssertTrue( + operation.isAsynchronous, "Operation should be asynchronous.") + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsCancelled.swift b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsCancelled.swift new file mode 100644 index 0000000..aee9b63 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsCancelled.swift @@ -0,0 +1,32 @@ +// +// OperationIsCancelled.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class OperationIsCancelled: OperationTestCase { + func testIsCancelled_notExecuting() { + var flag = false + let changeFlagOperation = ClosureOperation { + flag = true + } + changeFlagOperation.cancel() + concurrentQueue.addOperations( + [changeFlagOperation], waitUntilFinished: true) + XCTAssertFalse(flag, "The flag should not have been changed since the operation was cancelled.") + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsExecutingTestCase.swift b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsExecutingTestCase.swift new file mode 100644 index 0000000..d48de2d --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsExecutingTestCase.swift @@ -0,0 +1,46 @@ +// +// OperationIsExecutingTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import Foundation +@testable import RAD + +class OperationIsExecutingTestCase: OperationTestCase { + func testIsExecuting() { + let operation = PlainOperation() + + let isNotExecutingExpectation = KVOExpectation( + description: "'isExecuting' should be false", + object: operation, + keyPath: \RAD.Operation.isExecuting, + expectedValue: false) + let isExecutingExpectation = KVOExpectation( + description: "'isExecuting' should be true", + object: operation, + keyPath: \RAD.Operation.isExecuting, + expectedValue: true) + + XCTAssertFalse( + operation.isExecuting, + "Operation should be executing before is added in the queue.") + concurrentQueue.addOperation(operation) + wait( + for: [isExecutingExpectation, isNotExecutingExpectation], + timeout: TimeInterval.milliseconds(100), + enforceOrder: true) + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsFinishedTestCase.swift b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsFinishedTestCase.swift new file mode 100644 index 0000000..ffae1a7 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsFinishedTestCase.swift @@ -0,0 +1,37 @@ +// +// OperationIsFinishedTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class OperationIsFinishedTestCase: OperationTestCase { + func testIsFinished() { + let operation = PlainOperation() + let isFinishedExpectation = XCTKVOExpectation( + keyPath: RAD.Operation.isFinishedKey, + object: operation, + expectedValue: true) + + XCTAssertFalse( + operation.isFinished, + "Operation should not be finished before is added on the queue.") + concurrentQueue.addOperation(operation) + wait( + for: [isFinishedExpectation], + timeout: TimeInterval.milliseconds(100)) + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsReadyTestCase.swift b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsReadyTestCase.swift new file mode 100644 index 0000000..b5c3f7b --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/OperationIsReadyTestCase.swift @@ -0,0 +1,46 @@ +// +// OperationIsReadyTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class OperationIsReadyTestCase: XCTestCase { + func testIsReady_byDefault() { + let operation = StandByOperation() + XCTAssertFalse( + operation.isReady, + "StandByOperation should be not be ready by default.") + } + + func testIsReady_getsUpdated() { + let operation = StandByOperation() + let expectation = XCTKVOExpectation( + keyPath: "isReady", object: operation, expectedValue: true) + operation.updateReady(true) + wait(for: [expectation], timeout: TimeInterval.milliseconds(100)) + } + + func testIsReady_falseHavingDependencies() { + let operation = RAD.Operation() + let dependentOperation = RAD.Operation() + operation.addDependency(dependentOperation) + + XCTAssertFalse( + operation.isReady, + "Operation should not be ready while having dependencies.") + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/PlainOperation.swift b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/PlainOperation.swift new file mode 100644 index 0000000..fe95e6f --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/PlainOperation.swift @@ -0,0 +1,25 @@ +// +// PlainOperation.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +@testable import RAD + +class PlainOperation: RAD.Operation { + override func execute() { + finish() + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/StandByOperation.swift b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/StandByOperation.swift new file mode 100644 index 0000000..5fba2d5 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OperationTestSuite/StandByOperation.swift @@ -0,0 +1,26 @@ +// +// StandByOperation.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import Foundation +@testable import RAD + +class StandByOperation: RAD.Operation { + override init() { + super.init() + updateReady(false) + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OutputOperationTestSuite.swift b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OutputOperationTestSuite.swift new file mode 100644 index 0000000..a415754 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ChainedOperationsTests/OutputOperationTestSuite.swift @@ -0,0 +1,69 @@ +// +// OutputOperationTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class OutputOperationTestSuite: OperationTestCase { + func testCaseFor_finishWithOutput() { + let operation = BooleanOutputOperation() + let expectation = self.expectation( + description: "Output operation should have finished with object.") + let inputOperation = ClosureInputOperation { _ in + expectation.fulfill() + } + operation.chainOperation(with: inputOperation) + concurrentQueue.addOperations( + [operation, inputOperation], waitUntilFinished: false) + wait(for: [expectation], timeout: TimeInterval.milliseconds(100)) + } + + func testCaseFor_finishWithError() { + let operation = ErrorOutputOperation() + let inputOperation = ClosureInputOperation { _ in } + operation.chainOperation(with: inputOperation) + let expectation = self.expectation( + description: "Output operation should have finished with errror.") + operation.completionBlock = { [weak operation] in + guard let strongOperation = operation else { return } + if strongOperation.finishError != nil { + expectation.fulfill() + } + } + concurrentQueue.addOperations( + [operation, inputOperation], waitUntilFinished: true) + wait(for: [expectation], timeout: TimeInterval.milliseconds(100)) + XCTAssertTrue( + inputOperation.isCancelled, + "Input operation should have been cancelled.") + } + + func testCaseFor_cancellingOperation() { + let operation = BooleanOutputOperation() + let inputOperation = ClosureInputOperation { _ in } + operation.chainOperation(with: inputOperation) + operation.cancel() + concurrentQueue.addOperations( + [operation, inputOperation], waitUntilFinished: true) + XCTAssertTrue( + operation.isCancelled, + "Input operation should have been cancelled.") + XCTAssertTrue( + inputOperation.isCancelled, + "Input operation should have been cancelled.") + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/CoreDataTests/ContextTrasnferFetchTestSuite.swift b/RADTests/Tests/ModelTests/OperationTests/CoreDataTests/ContextTrasnferFetchTestSuite.swift new file mode 100644 index 0000000..547524e --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/CoreDataTests/ContextTrasnferFetchTestSuite.swift @@ -0,0 +1,131 @@ +// +// ContextTrasnferFetchTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import CoreData +@testable import RAD + +class ContextTrasnferFetchTestSuite: OperationTestCase { + override func setUp() { + Storage.shared?.load() + } + + func testContextTransfer() { + guard let backgroundContext = Storage.shared?.backgroundQueueContext + else { return } + guard let mainContext = Storage.shared?.mainQueueContext else { return } + let url = "https://remoteaudio.npr.org" + let operation = CreateServerOperation(url: + url, context: backgroundContext) + operation.completionBlock = { [weak operation] in + if operation?.finishError != nil { + XCTFail("Failed to finish with") + } + } + fetch( + url: url, + afterOperation: operation, + fromContext: backgroundContext, + toContext: mainContext + ) { assertion in + XCTAssertTrue( + assertion, "Object was not transferred between objects.") + } + } + + func fetch( + url: String, + afterOperation operation: OutputOperation<[Server]>, + fromContext: NSManagedObjectContext, + toContext: NSManagedObjectContext, + completion: @escaping (_ successful: Bool) -> Void + ) { + let transferOperation = ContextTransferOperation( + context: fromContext) + operation.chainOperation(with: transferOperation) + let fetchOperation = ContextFetchOperation(context: toContext) + transferOperation.chainOperation(with: fetchOperation) + let inputOperation = ClosureInputOperation<[Server]> { servers in + toContext.perform { + let object = servers.first(where: { + $0.trackingUrl == url + }) + completion(object != nil) + } + } + fetchOperation.chainOperation(with: inputOperation) + serialQueue.addOperations( + [operation, transferOperation, fetchOperation, inputOperation], + waitUntilFinished: false) + } + + func testCaseFor_transferOperation_unableInput() { + guard let mainContext = Storage.shared?.mainQueueContext else { return } + let operation = ContextTransferOperation(context: mainContext) + operation.updateReady(true) + + let expectation = self.expectation( + description: "Context transfer operation does not have input.") + operation.completionBlock = { [weak operation] in + if operation?.finishError != nil { + expectation.fulfill() + } + } + + serialQueue.addOperation(operation) + wait(for: [expectation], timeout: TimeInterval.seconds(1)) + } + + func testCaseFor_contextFetchOperation_unableInput() { + guard let mainContext = Storage.shared?.mainQueueContext else { return } + let operation = ContextFetchOperation(context: mainContext) + operation.updateReady(true) + + let expectation = self.expectation( + description: "Context fetch operation does not have input.") + operation.completionBlock = { [weak operation] in + if operation?.finishError != nil { + expectation.fulfill() + } + } + + serialQueue.addOperation(operation) + wait(for: [expectation], timeout: TimeInterval.seconds(1)) + } +} + +private class CreateServerOperation: OutputOperation<[Server]> { + private let url: String + private let context: NSManagedObjectContext + + init(url: String, context: NSManagedObjectContext) { + self.url = url + self.context = context + } + + override func execute() { + guard let server = Server(urlString: url, context: context) else { + finish(with: OutputError.computationError) + return + } + let saveOperation = SaveContextOperation(context: context) + saveOperation.completionBlock = { [weak self] in + self?.finish(with: [server]) + } + OperationQueue.current?.addOperation(saveOperation) + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/CoreDataTests/FetchOperationTestSuite.swift b/RADTests/Tests/ModelTests/OperationTests/CoreDataTests/FetchOperationTestSuite.swift new file mode 100644 index 0000000..51abcc0 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/CoreDataTests/FetchOperationTestSuite.swift @@ -0,0 +1,73 @@ +// +// FetchOperationTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import CoreData +@testable import RAD + +class FetchOperationTestSuite: OperationTestCase { + override func setUp() { + Storage.shared?.load() + } + + func testCaseFor_serverFetch() { + guard let context = Storage.shared?.mainQueueContext else { + XCTFail("Context is not available.") + return + } + + _ = Server( + urlString: "https://remoteaudiodata.npr.org", context: context) + let saveOperation = SaveContextOperation(context: context) + + let fetchOperation = FetchOperation(context: context) + fetchOperation.addDependency(saveOperation) + let expectation = self.expectation( + description: "Fetch was successful.") + let inputOperation = ClosureInputOperation<[Server]> { servers in + if servers.count > 0 { + expectation.fulfill() + } + } + fetchOperation.chainOperation(with: inputOperation) + serialQueue.addOperations( + [saveOperation, fetchOperation, inputOperation], + waitUntilFinished: false) + wait(for: [expectation], timeout: TimeInterval.seconds(2)) + } + + func testCaseFor_unknownEntity() { + guard let context = Storage.shared?.mainQueueContext else { + XCTFail("Context is not available.") + return + } + + let operation = FetchOperation(context: context) + let expectation = self.expectation( + description: "Entity should not found in the database model.") + operation.completionBlock = { [weak operation] in + if operation?.finishError != nil { + expectation.fulfill() + } + } + serialQueue.addOperation(operation) + wait(for: [expectation], timeout: TimeInterval.seconds(2)) + } +} + +private class NonModelClass: NSManagedObject { +} diff --git a/RADTests/Tests/ModelTests/OperationTests/CoreDataTests/SaveOperationTestCase.swift b/RADTests/Tests/ModelTests/OperationTests/CoreDataTests/SaveOperationTestCase.swift new file mode 100644 index 0000000..47c2eb6 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/CoreDataTests/SaveOperationTestCase.swift @@ -0,0 +1,46 @@ +// +// SaveOperationTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class SaveOperationTestCase: OperationTestCase { + func testCaseFor_skippingSave() { + guard let context = Storage.shared?.backgroundQueueContext else { + XCTFail("Context is not available") + return + } + let operation = SaveContextOperation(context: context) + XCTAssertFalse(context.hasChanges, "Context should not have any changes.") + serialQueue.addOperations([operation], waitUntilFinished: true) + XCTAssertFalse(context.hasChanges, "Context should not have any changes.") + } + + func testCaseFor_contextIsSaved() { + guard let context = Storage.shared?.backgroundQueueContext else { + XCTFail("Context is not available.") + return + } + + _ = Server( + urlString: "https://remoteaudiodata.npr.org", context: context) + XCTAssertTrue(context.hasChanges, "Context should have any changes.") + let operation = SaveContextOperation(context: context) + serialQueue.addOperations([operation], waitUntilFinished: true) + XCTAssertFalse(context.hasChanges, "Context should not have any changes.") + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/CreateItemSessionOperationTestSuite.swift b/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/CreateItemSessionOperationTestSuite.swift new file mode 100644 index 0000000..fa93082 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/CreateItemSessionOperationTestSuite.swift @@ -0,0 +1,194 @@ +// +// CreateItemSessionOperationTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +import CoreData +@testable import RAD + +class CreateItemSessionOperationTestSuite: AnalyticsTestCase, +RADExtractionTestCase { + + func testCaseFor_objectsAreCreatedInDatabase() { + guard let url = Bundle.testBundle.url( + forResource: "1_000Events2TrackingUrls", withExtension: "mp3" + ) else { + XCTFail("Asset is not available.") + return + } + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + player.play() + + let pauseExpectation = self.expectation( + description: "Player did pause.") + + DispatchQueue.background.asyncAfter(deadline: .now() + .seconds(2)) { + self.player.pause() + pauseExpectation.fulfill() + } + + wait(for: [pauseExpectation], timeout: TimeInterval.seconds(5)) + + guard let context = Storage.shared?.backgroundQueueContext else { + XCTFail("Database is not available.") + return + } + + guard let md5 = extractMD5(from: item) else { + XCTFail("RAD payload is not available or MD5 creation failed.") + return + } + + let radExpectation = self.createRadExpectation(for: md5, from: context) + let sessionExpectation = self.createSessionExpectation( + for: md5, from: context) + + wait( + for: [radExpectation, sessionExpectation], + timeout: TimeInterval.seconds(5)) + } + + func testCaseFor_databaseObjectsAreUnlockedOrInactive() { + guard let context = Storage.shared?.backgroundQueueContext else { + XCTFail("Database is not available.") + return + } + + checkInactiveSession(in: context) + checkUnlockedObjects(for: Rad.self, in: context) + checkUnlockedObjects(for: ItemSessionID.self, in: context) + } + + // MARK: Private functionality + + private func createRadExpectation( + for md5: String, + from context: NSManagedObjectContext + ) -> XCTestExpectation { + let fetchRADOperation = FetchOperation(context: context) + fetchRADOperation.input = NSPredicate( + format: "md5 == '\(md5)'", argumentArray: nil) + let expectation = self.expectation( + description: "Rad fetch.") + let inputOperation = ClosureInputOperation<[Rad]> { + XCTAssert($0.count > 0, "Rad object was not created") + XCTAssert($0.count < 2, "Rad object was duplicated.") + expectation.fulfill() + } + fetchRADOperation.chainOperation(with: inputOperation) + serialQueue.addOperations( + [fetchRADOperation, inputOperation], waitUntilFinished: false) + return expectation + } + + private func createSessionExpectation( + for md5: String, from context: NSManagedObjectContext + ) -> XCTestExpectation { + let fetchSessionIDOperation = FetchOperation( + context: context) + fetchSessionIDOperation.input = NSPredicate( + format: "rad.md5 == '\(md5)'", argumentArray: nil) + let expectation = self.expectation( + description: "Item Session ID fetch.") + let inputOperation = ClosureInputOperation<[ItemSessionID]> { + XCTAssert($0.count > 0, "Item object was not created") + XCTAssert($0.count < 2, "Item object was duplicated.") + let itemSessions = $0.first?.itemSessions?.allObjects as? [ItemSession] + if itemSessions?.count == 0 { + XCTFail("Item session was not created.") + } else { + expectation.fulfill() + } + } + fetchSessionIDOperation.chainOperation(with: inputOperation) + + serialQueue.addOperations( + [fetchSessionIDOperation, inputOperation], waitUntilFinished: false) + return expectation + } + + private func checkInactiveSession(in context: NSManagedObjectContext) { + let fetchOperation = FetchOperation(context: context) + let fetchExpectation = self.expectation( + description: "Item sessions checked.") + let inputOperation = ClosureInputOperation<[ItemSession]> { + $0.forEach({ itemSession in + XCTAssertFalse( + itemSession.isActive, "Item session should be inactive.") + }) + fetchExpectation.fulfill() + } + fetchOperation.chainOperation(with: inputOperation) + + serialQueue.addOperations( + [fetchOperation, inputOperation], + waitUntilFinished: false) + + wait(for: [fetchExpectation], timeout: TimeInterval.seconds(3)) + } + + private func checkUnlockedObjects( + for type: T.Type, + in context: NSManagedObjectContext + ) { + let fetchOperation = FetchOperation(context: context) + let fetchExpectation = self.expectation( + description: "Lockable objects checked.") + let inputOperation = ClosureInputOperation<[T]> { + $0.forEach({ lockableObject in + XCTAssertFalse( + lockableObject.isLocked, "Object should not be locked.") + }) + fetchExpectation.fulfill() + } + fetchOperation.chainOperation(with: inputOperation) + + serialQueue.addOperations( + [fetchOperation, inputOperation], + waitUntilFinished: false) + + wait(for: [fetchExpectation], timeout: TimeInterval.seconds(3)) + } + + private func checkUnlockedRadObjects(in context: NSManagedObjectContext) { + let fetchOperation = FetchOperation(context: context) + let fetchExpectation = self.expectation( + description: "Item sessions expectation.") + let inputOperation = ClosureInputOperation<[ItemSession]> { + $0.forEach({ itemSession in + XCTAssertFalse( + itemSession.isActive, "Item session should be inactive.") + }) + fetchExpectation.fulfill() + } + fetchOperation.chainOperation(with: inputOperation) + + serialQueue.addOperations( + [fetchOperation, inputOperation], + waitUntilFinished: false) + + wait(for: [fetchExpectation], timeout: TimeInterval.seconds(3)) + } +} + +private protocol Lockable { + var isLocked: Bool { get } +} + +extension RAD.Rad: Lockable {} +extension RAD.ItemSessionID: Lockable {} diff --git a/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/ItemSessionIDUnlockedTestSuite.swift b/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/ItemSessionIDUnlockedTestSuite.swift new file mode 100644 index 0000000..e57e2d9 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/ItemSessionIDUnlockedTestSuite.swift @@ -0,0 +1,143 @@ +// +// ItemSessionIDUnlockedTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +@testable import RAD + +class ItemSessionIDUnlockedTestSuite: AnalyticsTestCase, +RADExtractionTestCase { + func testCaseFor_sessionIDIsUnlocked_duringPlayback() { + guard let url = Bundle.testBundle.url( + forResource: "60Events2TrackingUrls", + withExtension: "mp3" + ) else { + XCTFail("Resource not available.") + return + } + + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + + player.play() + + let fetchExpectation = self.expectation( + description: "Item session id fetch.") + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(1), execute: { + guard let md5 = self.extractMD5(from: item) else { + XCTFail("Unable to created MD5 from RAD payload.") + return + } + self.expectUnlockedItemSessionID( + andFulfill: fetchExpectation, for: md5) + }) + + wait(for: [fetchExpectation], timeout: TimeInterval.seconds(5)) + } + + func testCaseFor_sessionIDIsUnlocked_afterPlayback() { + guard let url = Bundle.testBundle.url( + forResource: "80Events2TrackingUrls", + withExtension: "mp3" + ) else { + XCTFail("Resource not available.") + return + } + + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + + player.play() + + let itemReplacedExpectation = self.expectation( + description: "Player did replace item.") + + let fetchExpectation = self.expectation( + description: "Item session fetch.") + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(2), execute: { + self.player.pause() + self.player.replaceCurrentItem(with: nil) + itemReplacedExpectation.fulfill() + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(2), execute: { + guard let md5 = self.extractMD5(from: item) else { + XCTFail("Unable to create MD5 from RAD payload.") + return + } + self.expectUnlockedItemSessionID( + andFulfill: fetchExpectation, for: md5) + }) + }) + + wait( + for: [itemReplacedExpectation, fetchExpectation], + timeout: TimeInterval.seconds(10)) + } + + func testCaseFor_unavailableResource() { + guard let context = Storage.shared?.createContext else { + XCTFail("Resource not available.") + return + } + + let lockOperation = LockSessionOperation(context: context) + lockOperation.updateReady(true) + let failExpectation = self.expectation(description: "") + lockOperation.completionBlock = { [weak lockOperation] in + XCTAssertNotNil( + lockOperation?.finishError, + "Lock operation should have finished with error.") + failExpectation.fulfill() + } + + concurrentQueue.addOperation(lockOperation) + + wait(for: [failExpectation], timeout: TimeInterval.seconds(5)) + } + + // MARK: Private functionality + + private func expectUnlockedItemSessionID( + andFulfill expectation: XCTestExpectation, for md5: String + ) { + guard let context = Storage.shared?.createContext else { + XCTFail("Resource not available.") + return + } + + let fetchOperation = FetchOperation(context: context) + let md5Predicate = NSPredicate( + format: "rad.md5 == '\(md5)'", argumentArray: nil) + let isLockedPredicate = NSPredicate( + format: "isLocked == false", argumentArray: nil) + fetchOperation.input = NSCompoundPredicate( + andPredicateWithSubpredicates: [md5Predicate, isLockedPredicate]) + let inputOperation = ClosureInputOperation<[ItemSessionID]> { ids in + XCTAssert(ids.count == 1, "Item session is not active.") + expectation.fulfill() + } + fetchOperation.chainOperation(with: inputOperation) + + serialQueue.addOperations( + [fetchOperation, inputOperation], waitUntilFinished: false) + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/ItemSessionInactiveTestSuite.swift b/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/ItemSessionInactiveTestSuite.swift new file mode 100644 index 0000000..dc34dfc --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/ItemSessionInactiveTestSuite.swift @@ -0,0 +1,156 @@ +// +// ItemSessionInactiveTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +@testable import RAD + +class ItemSessionInactiveTestSuite: AnalyticsTestCase, RADExtractionTestCase { + func testCaseFor_itemSessionIsActiveDuringPlayback() { + guard let url = Bundle.testBundle.url( + forResource: "RAD_events", + withExtension: "m4a" + ) else { + XCTFail("Resource not available.") + return + } + + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + + player.play() + + let pauseExpectation = self.expectation( + description: "Player did pause.") + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(5), execute: { + self.player.pause() + pauseExpectation.fulfill() + }) + + let fetchExpectation = self.expectation( + description: "Item session fetch.") + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(2), execute: { + guard let md5 = self.extractMD5(from: item) else { + XCTFail("Unable to create MD5 from RAD payload.") + return + } + self.expectOneFetchedItemSession( + andFulfill: fetchExpectation, for: md5) + }) + + wait( + for: [pauseExpectation, fetchExpectation], + timeout: TimeInterval.minutes(1)) + } + + func testCaseFor_itemSessionIsInactiveAfterPlayback() { + guard let url = Bundle.testBundle.url( + forResource: "1_000Events2TrackingUrls", + withExtension: "mp3" + ) else { + XCTFail("Resource not available.") + return + } + + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + + player.play() + + let itemReplacedExpectation = self.expectation( + description: "Player did replace item.") + + let fetchExpectation = self.expectation( + description: "Item session fetch.") + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(15), execute: { + self.player.pause() + self.player.replaceCurrentItem(with: nil) + itemReplacedExpectation.fulfill() + + DispatchQueue.background.asyncAfter(deadline: .now() + .seconds(2)) { + guard let md5 = self.extractMD5(from: item) else { + XCTFail("Unable to create MD5 from RAD payload.") + return + } + self.expectInactiveSessions(fulfilling: fetchExpectation, for: md5) + } + }) + + wait( + for: [itemReplacedExpectation, fetchExpectation], + timeout: TimeInterval.minutes(1)) + } + + // MARK: Private functionality + + private func expectOneFetchedItemSession( + andFulfill expectation: XCTestExpectation, for md5: String + ) { + guard let context = Storage.shared?.createContext else { + XCTFail("Resource not available.") + return + } + + let fetchOperation = FetchOperation(context: context) + let md5Predicate = NSPredicate( + format: "sessionId.rad.md5 == '\(md5)'", argumentArray: nil) + let inactivePredicate = NSPredicate( + format: "isActive == true", argumentArray: nil) + fetchOperation.input = NSCompoundPredicate( + andPredicateWithSubpredicates: [md5Predicate, inactivePredicate]) + let inputOperation = ClosureInputOperation<[ItemSession]> { sessions in + XCTAssert(sessions.count == 1, "Item session is not active.") + expectation.fulfill() + } + fetchOperation.chainOperation(with: inputOperation) + + serialQueue.addOperations( + [fetchOperation, inputOperation], waitUntilFinished: false) + } + + private func expectInactiveSessions( + fulfilling expectation: XCTestExpectation, for md5: String + ) { + + guard let context = Storage.shared?.createContext else { + XCTFail("Resource not available.") + return + } + + let fetchOperation = FetchOperation(context: context) + let md5Predicate = NSPredicate( + format: "sessionId.rad.md5 == '\(md5)'", argumentArray: nil) + let inactivePredicate = NSPredicate( + format: "isActive == true", argumentArray: nil) + fetchOperation.input = NSCompoundPredicate( + andPredicateWithSubpredicates: [md5Predicate, inactivePredicate]) + let inputOperation = ClosureInputOperation<[ItemSession]> { sessions in + XCTAssert(sessions.count == 0, "No item session should be active.") + expectation.fulfill() + } + fetchOperation.chainOperation(with: inputOperation) + + serialQueue.addOperations( + [fetchOperation, inputOperation], waitUntilFinished: false) + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/ItemSessionRangesTestCase.swift b/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/ItemSessionRangesTestCase.swift new file mode 100644 index 0000000..2db6968 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/ItemSessionRangesTestCase.swift @@ -0,0 +1,144 @@ +// +// ItemSessionRangesTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import CoreData +import AVFoundation +@testable import RAD + +class ItemSessionRangesTestCase: AnalyticsTestCase, RADExtractionTestCase { + func testCreationOfRanges() { + guard let url = Bundle.testBundle.url( + forResource: "100Events2TrackingUrls", withExtension: "mp3" + ) else { + XCTFail("Asset is not available.") + return + } + let item = AVPlayerItem(url: url) + + guard let context = Storage.shared?.backgroundQueueContext else { + XCTFail("Database is not available.") + return + } + + createRanges(in: context, for: item) + + guard let md5 = extractMD5(from: item) else { + XCTFail("MD5 extraction failed") + return + } + + let fetchExpectation = fetchItemSessions( + for: md5, from: context, completion: { itemSessions in + let ranges = itemSessions.flatMap({ itemSession -> [RAD.Range] in + itemSession.playbackRanges?.allObjects as? [RAD.Range] ?? [] + }) + self.checkRanges(ranges, from: context, for: item) + }) + + wait(for: [fetchExpectation], timeout: TimeInterval.minutes(1)) + } + + // MARK: Private functionality + + private func createRanges( + in context: NSManagedObjectContext, + for item: AVPlayerItem + ) { + player.replaceCurrentItem(with: item) + + let seekExpectation = self.expectation(description: "Seek to second 7.") + let pauseExpectation = self.expectation( + description: "Pause on second 10.") + + DispatchQueue.background.asyncAfter(deadline: .now() + .seconds(5)) { + let time = CMTime( + seconds: 7.0, preferredTimescale: CMTime.TimeScale.podcast) + self.player.seek(to: time) + seekExpectation.fulfill() + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(3), + execute: { + self.player.pause() + pauseExpectation.fulfill() + }) + } + + wait( + for: [seekExpectation, pauseExpectation], + timeout: TimeInterval.seconds(15)) + } + + private func fetchItemSessions( + for md5: String, + from context: NSManagedObjectContext, + completion: @escaping ([ItemSession]) -> Void + ) -> XCTestExpectation { + let fetchSessionIDOperation = FetchOperation( + context: context) + fetchSessionIDOperation.input = NSPredicate( + format: "rad.md5 == '\(md5)'", argumentArray: nil) + let expectation = self.expectation( + description: "Item Session ID fetch.") + let inputOperation = ClosureInputOperation<[ItemSessionID]>( + closure: { ids in + XCTAssert( + ids.count == 1, + "Item object was not created or it was duplicated.") + let itemSessions = ids.first?.itemSessions?.allObjects as? [ItemSession] + if let itemSessions = itemSessions, itemSessions.count == 1 { + completion(itemSessions) + expectation.fulfill() + } else { + XCTFail("Item session was not created or it was duplicated.") + } + }) + fetchSessionIDOperation.chainOperation(with: inputOperation) + + serialQueue.addOperations( + [fetchSessionIDOperation, inputOperation], waitUntilFinished: false) + return expectation + } + + private func checkRanges( + _ ranges: [RAD.Range], + from context: NSManagedObjectContext, + for item: AVPlayerItem + ) { + ranges.forEach({ range in + guard let start = range.start else { + XCTFail("Invalid range.") + return + } + guard let end = range.end else { + XCTFail("Invalid range.") + return + } + if start.playerTime?.seconds.equals(to: 0, precision: 1) == true { + XCTAssert( + end.playerTime?.seconds.equals(to: 5, precision: 1) == true, + "Recorded range is not valid") + } else if start.playerTime?.seconds.equals(to: 7, precision: 1) == true { + XCTAssert( + end.playerTime?.seconds.equals(to: 10, precision: 1) == true, + "Recorded range is not valid") + } else { + XCTFail("Recorded range is not valid") + } + }) + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/RadIDUnlockedTestSuite.swift b/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/RadIDUnlockedTestSuite.swift new file mode 100644 index 0000000..55d155f --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/ItemSessionTests/RadIDUnlockedTestSuite.swift @@ -0,0 +1,146 @@ +// +// RadIDUnlockedTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +@testable import RAD + +class RadIDUnlockedTestSuite: AnalyticsTestCase, RADExtractionTestCase { + func testCaseFor_radIsUnlocked_duringPlayback() { + guard let url = Bundle.testBundle.url( + forResource: "180Events2TrackingUrls", + withExtension: "mp3" + ) else { + XCTFail("Resource not available.") + return + } + + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + + player.play() + + let fetchExpectation = self.expectation( + description: "Rad fetch.") + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(1), execute: { + guard let md5 = self.extractMD5(from: item) else { + XCTFail("Unable to created MD5 from RAD payload.") + return + } + self.expectUnlockedRad( + andFulfill: fetchExpectation, for: md5) + }) + + wait(for: [fetchExpectation], timeout: TimeInterval.seconds(5)) + } + + func testCaseFor_radIsUnlocked_afterPlayback() { + guard let url = Bundle.testBundle.url( + forResource: "240Events2TrackingUrls", + withExtension: "mp3" + ) else { + XCTFail("Resource not available.") + return + } + + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + + player.play() + + let itemReplacedExpectation = self.expectation( + description: "Player did replace item.") + + let fetchExpectation = self.expectation( + description: "Item session fetch.") + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(2), execute: { + self.player.pause() + self.player.replaceCurrentItem(with: nil) + itemReplacedExpectation.fulfill() + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(2), execute: { + guard let md5 = self.extractMD5(from: item) else { + XCTFail("Unable to create MD5 from RAD payload.") + return + } + self.expectUnlockedRad( + andFulfill: fetchExpectation, for: md5) + }) + }) + + wait( + for: [itemReplacedExpectation, fetchExpectation], + timeout: TimeInterval.seconds(10)) + } + + func testCaseFor_unavailableResource() { + guard let context = Storage.shared?.createContext else { + XCTFail("Resource not available.") + return + } + + let lockOperation = LockRadOperation(context: context) + lockOperation.updateReady(true) + let failExpectation = self.expectation(description: "") + lockOperation.completionBlock = { [weak lockOperation] in + XCTAssertNotNil( + lockOperation?.finishError, + "Lock operation should have finished with error.") + failExpectation.fulfill() + } + + concurrentQueue.addOperation(lockOperation) + + wait(for: [failExpectation], timeout: TimeInterval.seconds(5)) + } + + // MARK: Private functionality + + private func expectUnlockedRad( + andFulfill expectation: XCTestExpectation, + for md5: String, + file: StaticString = #file, + line: UInt = #line + ) { + guard let context = Storage.shared?.createContext else { + XCTFail("Resource not available.") + return + } + + let fetchOperation = FetchOperation(context: context) + let md5Predicate = NSPredicate( + format: "md5 == '\(md5)'", argumentArray: nil) + let isLockedPredicate = NSPredicate( + format: "isLocked == false", argumentArray: nil) + fetchOperation.input = NSCompoundPredicate( + andPredicateWithSubpredicates: [md5Predicate, isLockedPredicate]) + let inputOperation = ClosureInputOperation<[Rad]> { ids in + XCTAssert( + ids.count == 1, "Rad is not unlocked.", file: file, line: line) + expectation.fulfill() + } + fetchOperation.chainOperation(with: inputOperation) + + serialQueue.addOperations( + [fetchOperation, inputOperation], waitUntilFinished: false) + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/JsonParsing/ParseJSONOperationTestSuite.swift b/RADTests/Tests/ModelTests/OperationTests/JsonParsing/ParseJSONOperationTestSuite.swift new file mode 100644 index 0000000..ced2ddc --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/JsonParsing/ParseJSONOperationTestSuite.swift @@ -0,0 +1,79 @@ +// +// ParseJSONOperationTestSuite.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class ParseJSONOperationTestSuite: OperationTestCase { + func testCaseFor_successfulJSONParsing() { + let operation = ParseJSONOperation<[String]>() + operation.input = "[\"a\", \"b\"]" + let expectation = self.expectation( + description: "JSON parsing was successful.") + let input = ClosureInputOperation<[String]> { strings in + if strings == ["a", "b"] { + expectation.fulfill() + } + } + operation.chainOperation(with: input) + concurrentQueue.addOperations( + [operation, input], waitUntilFinished: false) + wait(for: [expectation], timeout: TimeInterval.milliseconds(100)) + } + + func testCaseFor_unavailableInput() { + let operation = ParseJSONOperation<[String]>() + operation.updateReady(true) + let expectation = self.expectation( + description: "JSON parsing did fail.") + operation.completionBlock = { [weak operation] in + if operation?.finishError != nil { + expectation.fulfill() + } + } + concurrentQueue.addOperation(operation) + wait(for: [expectation], timeout: TimeInterval.seconds(1)) + } + + func testCaseFor_failedJSONParsing() { + let operation = ParseJSONOperation<[String]>() + operation.input = "{\"key\":\"value;forgot closing brace\"" + let expectation = self.expectation( + description: "JSON parsing did fail.") + operation.completionBlock = { [weak operation] in + if operation?.finishError != nil { + expectation.fulfill() + } + } + concurrentQueue.addOperation(operation) + wait(for: [expectation], timeout: TimeInterval.seconds(1)) + } + + func testCaseFor_mismatchJSONTypes() { + let operation = ParseJSONOperation() + operation.input = "[\"a\", \"b\"]" + let expectation = self.expectation( + description: "JSON parsing did fail.") + operation.completionBlock = { [weak operation] in + if operation?.finishError != nil { + expectation.fulfill() + } + } + concurrentQueue.addOperation(operation) + wait(for: [expectation], timeout: TimeInterval.seconds(1)) + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/JsonParsing/ParseRADPayloadOperationTestCase.swift b/RADTests/Tests/ModelTests/OperationTests/JsonParsing/ParseRADPayloadOperationTestCase.swift new file mode 100644 index 0000000..84473a7 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/JsonParsing/ParseRADPayloadOperationTestCase.swift @@ -0,0 +1,45 @@ +// +// ParseRADPayloadOperationTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +@testable import RAD + +class ParseRADPayloadOperationTestCase: OperationTestCase { + func testRADPayloadExtraction() { + guard let url = Bundle.testBundle.url( + forResource: "RAD_extra_properties", + withExtension: "m4a" + ) else { + XCTFail("File resource is not available.") + return + } + + let asset = AVURLAsset(url: url) + let operation = ParseRADPayloadOperation(asset: asset) + let expectation = self.expectation( + description: "RAD payload extraction.") + let input = ClosureInputOperation { _ in + expectation.fulfill() + } + operation.chainOperation(with: input) + + concurrentQueue.addOperations( + [operation, input], waitUntilFinished: false) + wait(for: [expectation], timeout: TimeInterval.seconds(5)) + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/OperationTestCase.swift b/RADTests/Tests/ModelTests/OperationTests/OperationTestCase.swift new file mode 100644 index 0000000..ca45ec2 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/OperationTestCase.swift @@ -0,0 +1,30 @@ +// +// OperationTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class OperationTestCase: XCTestCase { + let concurrentQueue = OperationQueue.concurrent + let serialQueue = OperationQueue.serial + + override func setUp() { + super.setUp() + + Storage.shared?.load() + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/RADExtractionTestCase.swift b/RADTests/Tests/ModelTests/OperationTests/RADExtractionTestCase.swift new file mode 100644 index 0000000..36a6d91 --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/RADExtractionTestCase.swift @@ -0,0 +1,32 @@ +// +// RADExtractionTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import AVFoundation +@testable import RAD + +protocol RADExtractionTestCase { + func extractMD5(from item: AVPlayerItem) -> String? +} + +extension RADExtractionTestCase where Self: AnalyticsTestCase { + func extractMD5(from item: AVPlayerItem) -> String? { + let extractRADOperation = ParseRADPayloadOperation(asset: item.asset) + concurrentQueue.addOperations( + [extractRADOperation], waitUntilFinished: true) + return extractRADOperation.output?.md5 + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/SchedulingTests/AggregateOperationTestCase.swift b/RADTests/Tests/ModelTests/OperationTests/SchedulingTests/AggregateOperationTestCase.swift new file mode 100644 index 0000000..43c84bf --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/SchedulingTests/AggregateOperationTestCase.swift @@ -0,0 +1,52 @@ +// +// AggregateOperationTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class AggregateOperationTestCase: OperationTestCase { + func testFailedResult() { + let operation = AggregateOperation() + operation.add(.failed) + let expectation = self.expectation( + description: "Unexpected output result.") + let input = ClosureInputOperation { result in + if result == .failed { + expectation.fulfill() + } + } + operation.chainOperation(with: input) + concurrentQueue.addOperations( + [operation, input], waitUntilFinished: false) + wait(for: [expectation], timeout: TimeInterval.milliseconds(100)) + } + + func testDefaultResult() { + let operation = AggregateOperation() + let expectation = self.expectation( + description: "Unexpected output result.") + let input = ClosureInputOperation { result in + if result == .noData { + expectation.fulfill() + } + } + operation.chainOperation(with: input) + concurrentQueue.addOperations( + [operation, input], waitUntilFinished: false) + wait(for: [expectation], timeout: TimeInterval.milliseconds(100)) + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/SchedulingTests/NextScheduleOperationTestCase.swift b/RADTests/Tests/ModelTests/OperationTests/SchedulingTests/NextScheduleOperationTestCase.swift new file mode 100644 index 0000000..9fe6e9d --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/SchedulingTests/NextScheduleOperationTestCase.swift @@ -0,0 +1,52 @@ +// +// NextScheduleOperationTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class NextScheduleOperationTestCase: OperationTestCase { + override func tearDown() { + super.tearDown() + + UserDefaults.standard.removeObject( + forKey: UserDefaultsKeys.lastSchedule) + } + + func testNextSchedule() { + Scheduling.last = Date() + let configuration = Configuration( + submissionTimeInterval: TimeInterval.minutes(3), + batchSize: 5, + expirationTimeInterval: DateComponents(day: 1), + sessionExpirationTimeInterval: TimeInterval.hours(24), + requestHeaderFields: [:]) + let findOperation = FindNextSchedule(configuration: configuration) + let expectation = self.expectation( + description: "Next schedule is not computed correctly.") + let inputOperation = ClosureInputOperation { time in + let diff = time - Date().timeIntervalSinceNow + if diff - configuration.submissionTimeInterval < TimeInterval.milliseconds(100) { + expectation.fulfill() + } + } + findOperation.chainOperation(with: inputOperation) + serialQueue.addOperations( + [findOperation, inputOperation], + waitUntilFinished: false) + wait(for: [expectation], timeout: TimeInterval.minutes(100)) + } +} diff --git a/RADTests/Tests/ModelTests/OperationTests/SchedulingTests/WaitOperationTestCase.swift b/RADTests/Tests/ModelTests/OperationTests/SchedulingTests/WaitOperationTestCase.swift new file mode 100644 index 0000000..64c061d --- /dev/null +++ b/RADTests/Tests/ModelTests/OperationTests/SchedulingTests/WaitOperationTestCase.swift @@ -0,0 +1,73 @@ +// +// WaitOperationTestCase.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class WaitOperationTestCase: OperationTestCase { + func testforWaiting() { + let waitOperation = WaitOperation() + let waitTime = TimeInterval.seconds(1) + waitOperation.input = waitTime + let startTime = Date() + let expectation = self.expectation( + description: "Wait operation did not wait the correct amount of time.") + waitOperation.completionBlock = { + let endTime = Date() + let difference = endTime.timeIntervalSince(startTime) + if abs(difference - waitTime) < TimeInterval.milliseconds(100) { + expectation.fulfill() + } + } + concurrentQueue.addOperation(waitOperation) + wait(for: [expectation], + timeout: waitTime + TimeInterval.seconds(1)) + } + + func testFor_unavailableInput() { + let waitOperation = WaitOperation() + waitOperation.updateReady(true) + let expectation = self.expectation( + description: "Wait operation finishes with error.") + waitOperation.completionBlock = { [weak waitOperation] in + if waitOperation?.finishError != nil { + expectation.fulfill() + } + } + concurrentQueue.addOperation(waitOperation) + wait(for: [expectation], timeout: TimeInterval.seconds(1)) + } + + func testFor_negativeTimeInterval() { + let waitOperation = WaitOperation() + let waitTime = TimeInterval.seconds(-1) + waitOperation.input = waitTime + let startTime = Date() + let expectation = self.expectation( + description: "Wait operation did not wait the correct amount of time.") + waitOperation.completionBlock = { + let endTime = Date() + let difference = endTime.timeIntervalSince(startTime) + if difference < TimeInterval.seconds(3) { + expectation.fulfill() + } + } + concurrentQueue.addOperation(waitOperation) + wait(for: [expectation], + timeout: waitTime + TimeInterval.seconds(10)) + } +} diff --git a/RADTests/Tests/ModelTests/SchedulingTests/SimpleTestCaseFullScheduling.swift b/RADTests/Tests/ModelTests/SchedulingTests/SimpleTestCaseFullScheduling.swift new file mode 100644 index 0000000..9fb9c5a --- /dev/null +++ b/RADTests/Tests/ModelTests/SchedulingTests/SimpleTestCaseFullScheduling.swift @@ -0,0 +1,71 @@ +// +// SimpleTestCaseFullScheduling.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +@testable import RAD +import OHHTTPStubs + +class SimpleTestCaseFullScheduling: AnalyticsTestCase { + override var configuration: Configuration { + return Configuration( + submissionTimeInterval: TimeInterval.seconds(30), + batchSize: 10, + expirationTimeInterval: DateComponents(day: 14), + sessionExpirationTimeInterval: TimeInterval.hours(24), + requestHeaderFields: [:]) + } + + func testScheduling() { + guard let url = Bundle.testBundle.url( + forResource: "50Events2TrackingUrls", withExtension: "mp3" + ) else { + XCTFail("Resource is not available.") + return + } + + OHHTTPStubs.stubRequests(passingTest: { request -> Bool in + return request.url?.absoluteString == "https://www.npr.org" + }, withStubResponse: { request -> OHHTTPStubsResponse in + return OHHTTPStubsResponse( + jsonObject: [:], statusCode: 200, headers: nil) + }) + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + + player.play() + + let pauseExpectation = self.expectation( + description: "Player did pause.") + + DispatchQueue.background.asyncAfter( + deadline: .now() + .seconds(15), execute: { + self.player.pause() + pauseExpectation.fulfill() + }) + + let waitExpectation = self.expectation(description: "Waiting.") + + DispatchQueue.background.asyncAfter(deadline: .now() + .seconds(40)) { + waitExpectation.fulfill() + } + + wait( + for: [pauseExpectation, waitExpectation], + timeout: TimeInterval.minutes(1)) + } +} diff --git a/RADTests/Tests/ParsingTests.swift b/RADTests/Tests/ParsingTests.swift new file mode 100644 index 0000000..82e4ed3 --- /dev/null +++ b/RADTests/Tests/ParsingTests.swift @@ -0,0 +1,31 @@ +// +// ParsingTests.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +@testable import RAD + +private typealias StringValueJSON = [String: String] +private typealias StringValueJSONArray = [StringValueJSON] + +class ParsingTests: AnalyticsTestCase { + override func tearDown() { + super.tearDown() + + player.replaceCurrentItem(with: nil) + } +} diff --git a/RADTests/Tests/Player.swift b/RADTests/Tests/Player.swift new file mode 100644 index 0000000..b7534a9 --- /dev/null +++ b/RADTests/Tests/Player.swift @@ -0,0 +1,69 @@ +// +// Player.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import AVFoundation + +private class Observation {} + +class Player: AVPlayer { + private typealias Closure = () -> Void + + var scheduleOperation: Operation? { + guard closures.count > 0 else { return nil } + + let operation = BlockOperation { + self.closures.forEach({ + $0() + }) + } + operation.completionBlock = { + self.closures.removeAll() + } + return operation + } + + private var observers: [Observation] = [] + private var closures: [Closure] = [] + + deinit { + assert(observers.count == 0, "Observers have not been removed.") + } + + override func addBoundaryTimeObserver(forTimes times: [NSValue], + queue: DispatchQueue?, + using block: @escaping () -> Void) -> Any { + closures.append { + times.forEach({ + let queue = queue ?? DispatchQueue.main + queue.asyncAfter(deadline: .now() + $0.timeValue.seconds, + execute: block) + }) + } + let observation = Observation() + observers.append(observation) + return observation + } + + override func removeTimeObserver(_ observer: Any) { + guard let observation = observer as? Observation else { return } + if let index = observers.index(where: { + $0 === observation + }) { + observers.remove(at: index) + } + } +} diff --git a/RADTests/Tests/StorageTests.swift b/RADTests/Tests/StorageTests.swift new file mode 100644 index 0000000..fed0c05 --- /dev/null +++ b/RADTests/Tests/StorageTests.swift @@ -0,0 +1,31 @@ +// +// StorageTests.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +import AVFoundation +import CoreData +@testable import RAD + +class StorageTests: AnalyticsTestCase { + private var timeoutDelta: TimeInterval { + return 10 + } + + override var playerClass: AVPlayer.Type { + return Player.self + } +} diff --git a/RADTests/Tests/TimeComponentsTests.swift b/RADTests/Tests/TimeComponentsTests.swift new file mode 100644 index 0000000..7879b48 --- /dev/null +++ b/RADTests/Tests/TimeComponentsTests.swift @@ -0,0 +1,93 @@ +// +// TimeComponentsTests.swift +// RADTests +// +// Copyright 2018 NPR +// +// 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. +// + +import XCTest +@testable import RAD + +class TimeComponentsTests: XCTestCase { + func testTimeIntervalSuccess() { + let components = TimeComponents(hours: 1, minutes: 25, seconds: 0.5) + XCTAssert(components.timeInterval.equals(to: 5100.5), + "Time interval is not computed corretly.") + } + + func testTimeIntervalFailure() { + let components = TimeComponents(hours: 2, minutes: 30, seconds: 0) + XCTAssertFalse(components.timeInterval.equals(to: 5100), + "Time interval is not computed corretly.") + } + + func testDefaultConstructor() { + let components = TimeComponents(hours: 1, minutes: 25, seconds: 0.5) + XCTAssert(components.hours.value.equals(to: 1), + "Hours property is not set.") + XCTAssert(components.minutes.value.equals(to: 25), + "Minutes property is not set.") + XCTAssert(components.seconds.value.equals(to: 0.5), + "Seconds property is not set.") + } + + func testTimeIntervalConstructSuccess() { + let components = TimeComponents(timeInterval: 6600) + XCTAssert(components?.hours.value.equals(to: 1) == true, + "Hours component is not computed correctly.") + XCTAssert(components?.minutes.value.equals(to: 50) == true, + "Minutes component is not computed correctly.") + XCTAssert(components?.seconds.value.equals(to: 0) == true, + "Seconds component is not computed correctly.") + } + + func testTimeIntervalConstructFailure() { + let components = TimeComponents(timeInterval: 7500) + XCTAssertFalse(components?.hours.value.equals(to: 1) == true, + "Hours component is not computed correctly.") + XCTAssert(components?.minutes.value.equals(to: 5) == true, + "Minutes component is not computed correctly.") + } + + func testStringConstructorSuccess() { + let components = TimeComponents(string: "01:15:42", componentsSeparator: ":") + XCTAssertNotNil(components) + XCTAssert(components?.hours.value.equals(to: 1) == true, + "Hours component is not computed correctly.") + XCTAssert(components?.minutes.value.equals(to: 15) == true, + "Minutes component is not computed correctly.") + XCTAssert(components?.seconds.value.equals(to: 42) == true, + "Seconds component is not computed correctly.") + } + + func testStringConstructorSuccess2() { + let components = TimeComponents(string: "1;1;2", componentsSeparator: ";") + XCTAssert(components?.hours.value.equals(to: 1) == true, + "Hours component is not computed correctly.") + XCTAssert(components?.minutes.value.equals(to: 1) == true, + "Minutes component is not computed correctly.") + XCTAssert(components?.seconds.value.equals(to: 2) == true, + "Seconds component is not computed correctly.") + } + + func testStringConstructorSuccess3() { + let components = TimeComponents(string: "2:11:03.123", componentsSeparator: ":") + XCTAssertNotNil(components) + XCTAssert(components?.hours.value.equals(to: 2) == true, + "Hours component is not computed correctly.") + XCTAssert(components?.minutes.value.equals(to: 11) == true, + "Minutes component is not computed correctly.") + XCTAssert(components?.seconds.value.equals(to: 3.123) == true, + "Seconds component is not computed correctly.") + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0a77d1 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# RAD-iOS + +## RAD + +### Podcast Analytics + +Remote Audio Data is a framework for reporting the listenership of podcasts in iOS apps. + +## How to integrate RAD framework + +### [Carthage](https://github.com/Carthage/Carthage) + +Add RAD dependency in your Cartfile +``` +github "npr/RAD-iOS" +``` +and follow the [general flow](https://github.com/Carthage/Carthage#if-youre-building-for-ios-tvos-or-watchos) to add the .framework file into your project. + +### [CocoaPods](https://cocoapods.org) + +Support will be available soon. + +### Project integration + +The RAD framework consists of a single class `Analytics` which provides support to start collecting data from an `AVPlayer` and send the data to Analytics servers. + +Import the Swift module in the files which is used +```swift +import RAD +``` + +Within your business model create an instance of `Analytics`. +```swift +let analytics = Analytics() +``` +Or you use a singleton if it is appropriate: +```swift +static let RADAnalytics = Analytics() +``` +The Analytics object has a `configuration` property. By using this constructor, a default `Configuration` is used. +To see default values of [`Configuration`](RAD/RAD/Model/Entities/Configuration.swift), you can check the [`Analytics`](RAD/RAD/Analytics.swift) class. + +A custom configuration may be passed to the `Analytics` instance via constructor. +```swift +let configuration = Configuration( + submissionTimeInterval: TimeInterval.minutes(30), // how much time to wait until the stored events in the local storage are sent to analyics servers + batchSize: 10, // how many events are send per network request + expirationTimeInterval: DateComponents(day: 2), // how much time is an event valid + sessionExpirationTimeInterval: TimeInterval.hours(24), // how much time is a session identifier active + requestHeaderFields: [ + "UserAgent": "iPhone/iOS", + "MyCustomKey": "CustomValue"] // header fields which will be added on each network request +) +let analytics = Analytics(configuration: configuration) +``` + +To start recording data, pass an instance of `AVPlayer` to the instance of `Analytics`. Upon creating the player it is required to no initialize with any item, otherwise that item will not be analyzed. +```swift +let player = AVPlayer(playerItem: nil) // intialize the player +analytics.observePlayer(player) // start observing +player.replaceCurrentItem(with: playerItem) // change current item +``` + +To provide support for sending data while application is in background, it is required to enable [Background Fetch](https://developer.apple.com/documentation/uikit/core_app/managing_your_app_s_life_cycle/preparing_your_app_to_run_in_the_background/updating_your_app_with_background_app_refresh) and override [application(\_:performFetchWithCompletionHandler:)](). Example: +```swift +func application( + _ application: UIApplication, + performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void +) { + analytics.performBackgroundFetch(completion: completionHandler) +} +``` + +Sending data to the analytics servers may be stopped or started at anytime. By default, data is sent to the servers when the `Analytics` object is created. + +You can start and stop the data send using the following methods: +```swift +analytics.stopSendingData() // the next data send schedule is cancelled and data is not send to servers anymore +analytics.startSendingData() // schedule a point in time when to send data to servers based on configuration +``` + +## Demo + +A demo project is available. Before first run, it is required to checkout its dependencies using Carthage +``` +carthage update --platform iOS --cache-builds --no-use-binaries +``` \ No newline at end of file