├── .gitignore ├── AxinomDrmSamplePlayer ├── AxinomDrmSamplePlayer.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── AxinomDrmSamplePlayer.xcscheme ├── AxinomDrmSamplePlayer │ ├── AppDelegate.swift │ ├── Asset.swift │ ├── Assets Table │ │ ├── AssetListTableViewCell.swift │ │ └── AssetsViewController.swift │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── Icon-1024.png │ │ │ ├── Icon-120.png │ │ │ ├── Icon-121.png │ │ │ ├── Icon-152.png │ │ │ ├── Icon-167.png │ │ │ ├── Icon-180.png │ │ │ ├── Icon-20.png │ │ │ ├── Icon-29.png │ │ │ ├── Icon-40.png │ │ │ ├── Icon-41.png │ │ │ ├── Icon-42.png │ │ │ ├── Icon-58.png │ │ │ ├── Icon-59.png │ │ │ ├── Icon-60.png │ │ │ ├── Icon-76.png │ │ │ ├── Icon-80.png │ │ │ ├── Icon-81.png │ │ │ └── Icon-87.png │ │ └── Contents.json │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── ConsoleOverlayView.swift │ ├── Extensions + Utils │ │ ├── LogManager.swift │ │ ├── Notification.Name.swift │ │ └── Utils.swift │ ├── Info.plist │ ├── PlayerViewController.swift │ ├── SceneDelegate.swift │ └── Streams.json └── Managers │ ├── AssetDownloader.swift │ └── ContentKeyManager.swift ├── DownloadedFileStructure.png ├── InstallQR.png ├── LICENSE ├── OnlineScenario.png ├── PersistableScenario.png └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | *.xcuserstate 25 | *.xcbkptlist 26 | 27 | ## Obj-C/Swift specific 28 | *.hmap 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | .build/ 44 | 45 | # CocoaPods 46 | # 47 | # We recommend against adding the Pods directory to your .gitignore. However 48 | # you should judge for yourself, the pros and cons are mentioned at: 49 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 50 | # 51 | # Pods/ 52 | 53 | # Carthage 54 | # 55 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 56 | # Carthage/Checkouts 57 | 58 | Carthage/Build 59 | 60 | # fastlane 61 | # 62 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 63 | # screenshots whenever they are needed. 64 | # For more information about the recommended setup visit: 65 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 66 | 67 | fastlane/report.xml 68 | fastlane/Preview.html 69 | fastlane/screenshots/**/*.png 70 | fastlane/test_output 71 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | E4DDE9FA29C8BBC4009D1275 /* ConsoleOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4DDE9F929C8BBC4009D1275 /* ConsoleOverlayView.swift */; }; 11 | E60C651C25F8F243004441A7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60C651B25F8F243004441A7 /* LogManager.swift */; }; 12 | E6A64D8725669B540058D02E /* Notification.Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = E63B6A3E2541B6DC0091B371 /* Notification.Name.swift */; }; 13 | E6A64D8825669B540058D02E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6F43AA6247D0501000C1CF9 /* AppDelegate.swift */; }; 14 | E6A64D8925669B540058D02E /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E63B6A3F2541B6DC0091B371 /* Utils.swift */; }; 15 | E6A64D8A25669B540058D02E /* ContentKeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6AF1ECA2547136A0003BA6C /* ContentKeyManager.swift */; }; 16 | E6A64D8B25669B540058D02E /* Asset.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E3F366253D8907006D38CD /* Asset.swift */; }; 17 | E6A64D8C25669B540058D02E /* AssetDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D3666E2519FC68006FC03B /* AssetDownloader.swift */; }; 18 | E6A64D8D25669B540058D02E /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6F43AA8247D0501000C1CF9 /* SceneDelegate.swift */; }; 19 | E6A64D8E25669B540058D02E /* AssetListTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E63B6A392541B6D20091B371 /* AssetListTableViewCell.swift */; }; 20 | E6A64D8F25669B540058D02E /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6580913247F9123007F0771 /* PlayerViewController.swift */; }; 21 | E6A64D9025669B540058D02E /* AssetsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E63B6A3A2541B6D20091B371 /* AssetsViewController.swift */; }; 22 | E6A64D9325669B540058D02E /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E6F43AB1247D0504000C1CF9 /* LaunchScreen.storyboard */; }; 23 | E6A64D9425669B540058D02E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E6F43AAF247D0504000C1CF9 /* Assets.xcassets */; }; 24 | E6A64D9525669B540058D02E /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E6F43AAC247D0501000C1CF9 /* Main.storyboard */; }; 25 | E6A64D9625669B540058D02E /* Streams.json in Resources */ = {isa = PBXBuildFile; fileRef = E685D1F72524AFC7003900A2 /* Streams.json */; }; 26 | /* End PBXBuildFile section */ 27 | 28 | /* Begin PBXFileReference section */ 29 | E4DDE9F929C8BBC4009D1275 /* ConsoleOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleOverlayView.swift; sourceTree = ""; }; 30 | E60C651B25F8F243004441A7 /* LogManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = ""; }; 31 | E63B6A392541B6D20091B371 /* AssetListTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AssetListTableViewCell.swift; path = "AxinomDrmSamplePlayer/Assets Table/AssetListTableViewCell.swift"; sourceTree = SOURCE_ROOT; }; 32 | E63B6A3A2541B6D20091B371 /* AssetsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AssetsViewController.swift; path = "AxinomDrmSamplePlayer/Assets Table/AssetsViewController.swift"; sourceTree = SOURCE_ROOT; }; 33 | E63B6A3E2541B6DC0091B371 /* Notification.Name.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Notification.Name.swift; path = "AxinomDrmSamplePlayer/Extensions + Utils/Notification.Name.swift"; sourceTree = SOURCE_ROOT; }; 34 | E63B6A3F2541B6DC0091B371 /* Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Utils.swift; path = "AxinomDrmSamplePlayer/Extensions + Utils/Utils.swift"; sourceTree = SOURCE_ROOT; }; 35 | E6580913247F9123007F0771 /* PlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewController.swift; sourceTree = ""; }; 36 | E685D1F72524AFC7003900A2 /* Streams.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Streams.json; sourceTree = ""; }; 37 | E6A64D9A25669B540058D02E /* AxinomDrmSamplePlayer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AxinomDrmSamplePlayer.app; sourceTree = BUILT_PRODUCTS_DIR; }; 38 | E6AF1ECA2547136A0003BA6C /* ContentKeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentKeyManager.swift; sourceTree = ""; }; 39 | E6D3666E2519FC68006FC03B /* AssetDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetDownloader.swift; sourceTree = ""; }; 40 | E6E3F366253D8907006D38CD /* Asset.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Asset.swift; sourceTree = ""; }; 41 | E6F43AA6247D0501000C1CF9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 42 | E6F43AA8247D0501000C1CF9 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 43 | E6F43AAD247D0501000C1CF9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 44 | E6F43AAF247D0504000C1CF9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 45 | E6F43AB2247D0504000C1CF9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 46 | E6F43AB4247D0504000C1CF9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 47 | /* End PBXFileReference section */ 48 | 49 | /* Begin PBXFrameworksBuildPhase section */ 50 | E6A64D9125669B540058D02E /* Frameworks */ = { 51 | isa = PBXFrameworksBuildPhase; 52 | buildActionMask = 2147483647; 53 | files = ( 54 | ); 55 | runOnlyForDeploymentPostprocessing = 0; 56 | }; 57 | /* End PBXFrameworksBuildPhase section */ 58 | 59 | /* Begin PBXGroup section */ 60 | E63EEBA0251B4806008D348C /* Extensions + Utils */ = { 61 | isa = PBXGroup; 62 | children = ( 63 | E63B6A3E2541B6DC0091B371 /* Notification.Name.swift */, 64 | E60C651B25F8F243004441A7 /* LogManager.swift */, 65 | E63B6A3F2541B6DC0091B371 /* Utils.swift */, 66 | ); 67 | name = "Extensions + Utils"; 68 | path = "AxinomDrmSamplePlayer/Extensions + Utils"; 69 | sourceTree = ""; 70 | }; 71 | E66189F22541970E0084431C /* Assets Table */ = { 72 | isa = PBXGroup; 73 | children = ( 74 | E63B6A392541B6D20091B371 /* AssetListTableViewCell.swift */, 75 | E63B6A3A2541B6D20091B371 /* AssetsViewController.swift */, 76 | ); 77 | name = "Assets Table"; 78 | path = "AxinomDrmSamplePlayer/Assets Table"; 79 | sourceTree = ""; 80 | }; 81 | E6AF1ED2254725C80003BA6C /* Managers */ = { 82 | isa = PBXGroup; 83 | children = ( 84 | E6D3666E2519FC68006FC03B /* AssetDownloader.swift */, 85 | E6AF1ECA2547136A0003BA6C /* ContentKeyManager.swift */, 86 | ); 87 | path = Managers; 88 | sourceTree = ""; 89 | }; 90 | E6F43A9A247D0501000C1CF9 = { 91 | isa = PBXGroup; 92 | children = ( 93 | E6F43AA5247D0501000C1CF9 /* AxinomDrmSamplePlayer */, 94 | E6AF1ED2254725C80003BA6C /* Managers */, 95 | E66189F22541970E0084431C /* Assets Table */, 96 | E63EEBA0251B4806008D348C /* Extensions + Utils */, 97 | E6F43AA4247D0501000C1CF9 /* Products */, 98 | ); 99 | sourceTree = ""; 100 | }; 101 | E6F43AA4247D0501000C1CF9 /* Products */ = { 102 | isa = PBXGroup; 103 | children = ( 104 | E6A64D9A25669B540058D02E /* AxinomDrmSamplePlayer.app */, 105 | ); 106 | name = Products; 107 | sourceTree = ""; 108 | }; 109 | E6F43AA5247D0501000C1CF9 /* AxinomDrmSamplePlayer */ = { 110 | isa = PBXGroup; 111 | children = ( 112 | E6E3F366253D8907006D38CD /* Asset.swift */, 113 | E4DDE9F929C8BBC4009D1275 /* ConsoleOverlayView.swift */, 114 | E6580913247F9123007F0771 /* PlayerViewController.swift */, 115 | E6F43AA6247D0501000C1CF9 /* AppDelegate.swift */, 116 | E6F43AA8247D0501000C1CF9 /* SceneDelegate.swift */, 117 | E6F43AAC247D0501000C1CF9 /* Main.storyboard */, 118 | E6F43AB1247D0504000C1CF9 /* LaunchScreen.storyboard */, 119 | E685D1F72524AFC7003900A2 /* Streams.json */, 120 | E6F43AB4247D0504000C1CF9 /* Info.plist */, 121 | E6F43AAF247D0504000C1CF9 /* Assets.xcassets */, 122 | ); 123 | path = AxinomDrmSamplePlayer; 124 | sourceTree = ""; 125 | }; 126 | /* End PBXGroup section */ 127 | 128 | /* Begin PBXNativeTarget section */ 129 | E6A64D8525669B540058D02E /* AxinomDrmSamplePlayer */ = { 130 | isa = PBXNativeTarget; 131 | buildConfigurationList = E6A64D9725669B540058D02E /* Build configuration list for PBXNativeTarget "AxinomDrmSamplePlayer" */; 132 | buildPhases = ( 133 | E6A64D8625669B540058D02E /* Sources */, 134 | E6A64D9125669B540058D02E /* Frameworks */, 135 | E6A64D9225669B540058D02E /* Resources */, 136 | ); 137 | buildRules = ( 138 | ); 139 | dependencies = ( 140 | ); 141 | name = AxinomDrmSamplePlayer; 142 | productName = AxinomDrmSamplePlayer; 143 | productReference = E6A64D9A25669B540058D02E /* AxinomDrmSamplePlayer.app */; 144 | productType = "com.apple.product-type.application"; 145 | }; 146 | /* End PBXNativeTarget section */ 147 | 148 | /* Begin PBXProject section */ 149 | E6F43A9B247D0501000C1CF9 /* Project object */ = { 150 | isa = PBXProject; 151 | attributes = { 152 | LastSwiftUpdateCheck = 1130; 153 | LastUpgradeCheck = 1200; 154 | ORGANIZATIONNAME = Axinom; 155 | }; 156 | buildConfigurationList = E6F43A9E247D0501000C1CF9 /* Build configuration list for PBXProject "AxinomDrmSamplePlayer" */; 157 | compatibilityVersion = "Xcode 9.3"; 158 | developmentRegion = en; 159 | hasScannedForEncodings = 0; 160 | knownRegions = ( 161 | en, 162 | Base, 163 | ); 164 | mainGroup = E6F43A9A247D0501000C1CF9; 165 | productRefGroup = E6F43AA4247D0501000C1CF9 /* Products */; 166 | projectDirPath = ""; 167 | projectRoot = ""; 168 | targets = ( 169 | E6A64D8525669B540058D02E /* AxinomDrmSamplePlayer */, 170 | ); 171 | }; 172 | /* End PBXProject section */ 173 | 174 | /* Begin PBXResourcesBuildPhase section */ 175 | E6A64D9225669B540058D02E /* Resources */ = { 176 | isa = PBXResourcesBuildPhase; 177 | buildActionMask = 2147483647; 178 | files = ( 179 | E6A64D9325669B540058D02E /* LaunchScreen.storyboard in Resources */, 180 | E6A64D9425669B540058D02E /* Assets.xcassets in Resources */, 181 | E6A64D9525669B540058D02E /* Main.storyboard in Resources */, 182 | E6A64D9625669B540058D02E /* Streams.json in Resources */, 183 | ); 184 | runOnlyForDeploymentPostprocessing = 0; 185 | }; 186 | /* End PBXResourcesBuildPhase section */ 187 | 188 | /* Begin PBXSourcesBuildPhase section */ 189 | E6A64D8625669B540058D02E /* Sources */ = { 190 | isa = PBXSourcesBuildPhase; 191 | buildActionMask = 2147483647; 192 | files = ( 193 | E6A64D8725669B540058D02E /* Notification.Name.swift in Sources */, 194 | E60C651C25F8F243004441A7 /* LogManager.swift in Sources */, 195 | E6A64D8825669B540058D02E /* AppDelegate.swift in Sources */, 196 | E6A64D8925669B540058D02E /* Utils.swift in Sources */, 197 | E6A64D8A25669B540058D02E /* ContentKeyManager.swift in Sources */, 198 | E6A64D8B25669B540058D02E /* Asset.swift in Sources */, 199 | E6A64D8C25669B540058D02E /* AssetDownloader.swift in Sources */, 200 | E4DDE9FA29C8BBC4009D1275 /* ConsoleOverlayView.swift in Sources */, 201 | E6A64D8D25669B540058D02E /* SceneDelegate.swift in Sources */, 202 | E6A64D8E25669B540058D02E /* AssetListTableViewCell.swift in Sources */, 203 | E6A64D8F25669B540058D02E /* PlayerViewController.swift in Sources */, 204 | E6A64D9025669B540058D02E /* AssetsViewController.swift in Sources */, 205 | ); 206 | runOnlyForDeploymentPostprocessing = 0; 207 | }; 208 | /* End PBXSourcesBuildPhase section */ 209 | 210 | /* Begin PBXVariantGroup section */ 211 | E6F43AAC247D0501000C1CF9 /* Main.storyboard */ = { 212 | isa = PBXVariantGroup; 213 | children = ( 214 | E6F43AAD247D0501000C1CF9 /* Base */, 215 | ); 216 | name = Main.storyboard; 217 | sourceTree = ""; 218 | }; 219 | E6F43AB1247D0504000C1CF9 /* LaunchScreen.storyboard */ = { 220 | isa = PBXVariantGroup; 221 | children = ( 222 | E6F43AB2247D0504000C1CF9 /* Base */, 223 | ); 224 | name = LaunchScreen.storyboard; 225 | sourceTree = ""; 226 | }; 227 | /* End PBXVariantGroup section */ 228 | 229 | /* Begin XCBuildConfiguration section */ 230 | E6A64D9825669B540058D02E /* Debug */ = { 231 | isa = XCBuildConfiguration; 232 | buildSettings = { 233 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 234 | CODE_SIGN_IDENTITY = "Apple Development"; 235 | CODE_SIGN_STYLE = Automatic; 236 | DEVELOPMENT_TEAM = 53T55F7D66; 237 | INFOPLIST_FILE = AxinomDrmSamplePlayer/Info.plist; 238 | LD_RUNPATH_SEARCH_PATHS = ( 239 | "$(inherited)", 240 | "@executable_path/Frameworks", 241 | ); 242 | MARKETING_VERSION = 1.4; 243 | PRODUCT_BUNDLE_IDENTIFIER = com.axinom.vtb.AxinomDrmSamplePlayer; 244 | PRODUCT_NAME = "$(TARGET_NAME)"; 245 | PROVISIONING_PROFILE_SPECIFIER = ""; 246 | SWIFT_VERSION = 5.0; 247 | TARGETED_DEVICE_FAMILY = "1,2"; 248 | }; 249 | name = Debug; 250 | }; 251 | E6A64D9925669B540058D02E /* Release */ = { 252 | isa = XCBuildConfiguration; 253 | buildSettings = { 254 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 255 | CODE_SIGN_IDENTITY = "Apple Development"; 256 | CODE_SIGN_STYLE = Automatic; 257 | DEVELOPMENT_TEAM = 53T55F7D66; 258 | INFOPLIST_FILE = AxinomDrmSamplePlayer/Info.plist; 259 | LD_RUNPATH_SEARCH_PATHS = ( 260 | "$(inherited)", 261 | "@executable_path/Frameworks", 262 | ); 263 | MARKETING_VERSION = 1.4; 264 | PRODUCT_BUNDLE_IDENTIFIER = com.axinom.vtb.AxinomDrmSamplePlayer; 265 | PRODUCT_NAME = "$(TARGET_NAME)"; 266 | PROVISIONING_PROFILE_SPECIFIER = ""; 267 | SWIFT_VERSION = 5.0; 268 | TARGETED_DEVICE_FAMILY = "1,2"; 269 | }; 270 | name = Release; 271 | }; 272 | E6F43AB5247D0504000C1CF9 /* Debug */ = { 273 | isa = XCBuildConfiguration; 274 | buildSettings = { 275 | ALWAYS_SEARCH_USER_PATHS = NO; 276 | CLANG_ANALYZER_NONNULL = YES; 277 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 278 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 279 | CLANG_CXX_LIBRARY = "libc++"; 280 | CLANG_ENABLE_MODULES = YES; 281 | CLANG_ENABLE_OBJC_ARC = YES; 282 | CLANG_ENABLE_OBJC_WEAK = YES; 283 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 284 | CLANG_WARN_BOOL_CONVERSION = YES; 285 | CLANG_WARN_COMMA = YES; 286 | CLANG_WARN_CONSTANT_CONVERSION = YES; 287 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 288 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 289 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 290 | CLANG_WARN_EMPTY_BODY = YES; 291 | CLANG_WARN_ENUM_CONVERSION = YES; 292 | CLANG_WARN_INFINITE_RECURSION = YES; 293 | CLANG_WARN_INT_CONVERSION = YES; 294 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 295 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 296 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 297 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 298 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 299 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 300 | CLANG_WARN_STRICT_PROTOTYPES = YES; 301 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 302 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 303 | CLANG_WARN_UNREACHABLE_CODE = YES; 304 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 305 | COPY_PHASE_STRIP = NO; 306 | DEBUG_INFORMATION_FORMAT = dwarf; 307 | ENABLE_STRICT_OBJC_MSGSEND = YES; 308 | ENABLE_TESTABILITY = YES; 309 | GCC_C_LANGUAGE_STANDARD = gnu11; 310 | GCC_DYNAMIC_NO_PIC = NO; 311 | GCC_NO_COMMON_BLOCKS = YES; 312 | GCC_OPTIMIZATION_LEVEL = 0; 313 | GCC_PREPROCESSOR_DEFINITIONS = ( 314 | "DEBUG=1", 315 | "$(inherited)", 316 | ); 317 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 318 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 319 | GCC_WARN_UNDECLARED_SELECTOR = YES; 320 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 321 | GCC_WARN_UNUSED_FUNCTION = YES; 322 | GCC_WARN_UNUSED_VARIABLE = YES; 323 | IPHONEOS_DEPLOYMENT_TARGET = 13.1; 324 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 325 | MTL_FAST_MATH = YES; 326 | ONLY_ACTIVE_ARCH = YES; 327 | SDKROOT = iphoneos; 328 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 329 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 330 | }; 331 | name = Debug; 332 | }; 333 | E6F43AB6247D0504000C1CF9 /* Release */ = { 334 | isa = XCBuildConfiguration; 335 | buildSettings = { 336 | ALWAYS_SEARCH_USER_PATHS = NO; 337 | CLANG_ANALYZER_NONNULL = YES; 338 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 339 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 340 | CLANG_CXX_LIBRARY = "libc++"; 341 | CLANG_ENABLE_MODULES = YES; 342 | CLANG_ENABLE_OBJC_ARC = YES; 343 | CLANG_ENABLE_OBJC_WEAK = YES; 344 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 345 | CLANG_WARN_BOOL_CONVERSION = YES; 346 | CLANG_WARN_COMMA = YES; 347 | CLANG_WARN_CONSTANT_CONVERSION = YES; 348 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 349 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 350 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 351 | CLANG_WARN_EMPTY_BODY = YES; 352 | CLANG_WARN_ENUM_CONVERSION = YES; 353 | CLANG_WARN_INFINITE_RECURSION = YES; 354 | CLANG_WARN_INT_CONVERSION = YES; 355 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 356 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 357 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 358 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 359 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 360 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 361 | CLANG_WARN_STRICT_PROTOTYPES = YES; 362 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 363 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 364 | CLANG_WARN_UNREACHABLE_CODE = YES; 365 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 366 | COPY_PHASE_STRIP = NO; 367 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 368 | ENABLE_NS_ASSERTIONS = NO; 369 | ENABLE_STRICT_OBJC_MSGSEND = YES; 370 | GCC_C_LANGUAGE_STANDARD = gnu11; 371 | GCC_NO_COMMON_BLOCKS = YES; 372 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 373 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 374 | GCC_WARN_UNDECLARED_SELECTOR = YES; 375 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 376 | GCC_WARN_UNUSED_FUNCTION = YES; 377 | GCC_WARN_UNUSED_VARIABLE = YES; 378 | IPHONEOS_DEPLOYMENT_TARGET = 13.1; 379 | MTL_ENABLE_DEBUG_INFO = NO; 380 | MTL_FAST_MATH = YES; 381 | SDKROOT = iphoneos; 382 | SWIFT_COMPILATION_MODE = wholemodule; 383 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 384 | VALIDATE_PRODUCT = YES; 385 | }; 386 | name = Release; 387 | }; 388 | /* End XCBuildConfiguration section */ 389 | 390 | /* Begin XCConfigurationList section */ 391 | E6A64D9725669B540058D02E /* Build configuration list for PBXNativeTarget "AxinomDrmSamplePlayer" */ = { 392 | isa = XCConfigurationList; 393 | buildConfigurations = ( 394 | E6A64D9825669B540058D02E /* Debug */, 395 | E6A64D9925669B540058D02E /* Release */, 396 | ); 397 | defaultConfigurationIsVisible = 0; 398 | defaultConfigurationName = Release; 399 | }; 400 | E6F43A9E247D0501000C1CF9 /* Build configuration list for PBXProject "AxinomDrmSamplePlayer" */ = { 401 | isa = XCConfigurationList; 402 | buildConfigurations = ( 403 | E6F43AB5247D0504000C1CF9 /* Debug */, 404 | E6F43AB6247D0504000C1CF9 /* Release */, 405 | ); 406 | defaultConfigurationIsVisible = 0; 407 | defaultConfigurationName = Release; 408 | }; 409 | /* End XCConfigurationList section */ 410 | }; 411 | rootObject = E6F43A9B247D0501000C1CF9 /* Project object */; 412 | } 413 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer.xcodeproj/xcshareddata/xcschemes/AxinomDrmSamplePlayer.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 58 | 59 | 60 | 61 | 67 | 69 | 75 | 76 | 77 | 78 | 80 | 81 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2020 Axinom. All rights reserved. 3 | // 4 | // AppDelegate is the AppDelegate for this sample. No additionl work performed in this class. 5 | // 6 | 7 | import UIKit 8 | import AVFoundation 9 | 10 | @UIApplicationMain 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | // MARK: UISceneSession Lifecycle 14 | 15 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 16 | // Called when a new scene session is being created. 17 | // Use this method to select a configuration to create the new scene with. 18 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Asset.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2020 Axinom. All rights reserved. 3 | // 4 | // A class that holds information about an Asset 5 | // Adds Asset's AVURLAsset as a recipient to the Playback Content Key Session in a protected playback/download use case. 6 | // 7 | // DownloadState extension is used to track the download states of Assets, 8 | // Keys extension is used to define a number of values to use as keys in dictionary lookups. 9 | // 10 | 11 | import AVFoundation 12 | 13 | class Asset { 14 | 15 | var name: String 16 | var url: URL! 17 | var contentKeyIdList: [String]? 18 | var urlAsset: AVURLAsset! 19 | 20 | init(name: String, url: URL) { 21 | self.name = name 22 | self.url = url 23 | self.contentKeyIdList = [String]() 24 | 25 | print("Creating Asset with url: \(url)) name: \(name)") 26 | 27 | createUrlAsset() 28 | } 29 | 30 | // Link AVURLAsset to Content Key Session 31 | func addAsContentKeyRecipient() { 32 | print("Adding AVURLAsset as a recepient to the Content Key Session") 33 | ContentKeyManager.sharedManager.contentKeySession.addContentKeyRecipient(urlAsset) 34 | } 35 | 36 | // Using different AVURLAsset to allow simultaneous playback and download 37 | func createUrlAsset() { 38 | urlAsset = AVURLAsset(url: url) 39 | } 40 | } 41 | 42 | /* 43 | Extends `Asset` to add a simple download state enumeration used by the sample 44 | to track the download states of Assets. 45 | */ 46 | extension Asset { 47 | enum DownloadState: String { 48 | case notDownloaded 49 | case downloading 50 | case downloadedAndSavedToDevice 51 | } 52 | } 53 | 54 | /* 55 | Extends `Asset` to define a number of values to use as keys in dictionary lookups. 56 | */ 57 | extension Asset { 58 | struct Keys { 59 | /* 60 | Key for the Asset name, used for `AssetDownloadProgressNotification` and 61 | `AssetDownloadStateChangedNotification` Notifications as well as 62 | AssetListManager. 63 | */ 64 | static let name = "AssetNameKey" 65 | 66 | /* 67 | Key for the Asset download percentage, used for 68 | `AssetDownloadProgressNotification` Notification. 69 | */ 70 | static let percentDownloaded = "AssetPercentDownloadedKey" 71 | 72 | /* 73 | Key for the Asset download state, used for 74 | `AssetDownloadStateChangedNotification` Notification. 75 | */ 76 | static let downloadState = "AssetDownloadStateKey" 77 | 78 | /* 79 | Key for the Asset download AVMediaSelection display Name, used for 80 | `AssetDownloadStateChangedNotification` Notification. 81 | */ 82 | static let downloadSelectionDisplayName = "AssetDownloadSelectionDisplayNameKey" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets Table/AssetListTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2020 Axinom. All rights reserved. 3 | // 4 | // AssetListTableViewCell is the UITableViewCell subclass that represents an Asset visually in 5 | // AssetsViewController. This cell handles responding to user events as well as updating itself to reflect the 6 | // state of the Asset if it has been downloaded, deleted, or is actively downloading. 7 | // 8 | 9 | import UIKit 10 | import AVFoundation 11 | 12 | class AssetListTableViewCell: UITableViewCell { 13 | static let reuseIdentifier = "AssetListTableViewCellIdentifier" 14 | 15 | @IBOutlet weak var assetNameLabel: UILabel! 16 | 17 | @IBOutlet weak var downloadStateLabel: UILabel! 18 | 19 | @IBOutlet weak var downloadProgressView: UIProgressView! 20 | 21 | weak var delegate: AssetListTableViewCellDelegate? 22 | 23 | var asset: Asset? { 24 | didSet { 25 | if let asset = asset { 26 | // Initial label text and progress bar visibility according to download state 27 | let downloadState = AssetDownloader.sharedDownloader.downloadStateOfAsset(asset: asset) 28 | switch downloadState { 29 | case .downloadedAndSavedToDevice: 30 | downloadProgressView.isHidden = true 31 | case .downloading: 32 | downloadProgressView.isHidden = false 33 | case .notDownloaded: 34 | break 35 | } 36 | 37 | downloadStateLabel.text = downloadState.rawValue 38 | 39 | let notificationCenter = NotificationCenter.default 40 | notificationCenter.addObserver(self, 41 | selector: #selector(handleAssetDownloadStateChanged(_:)), 42 | name: .AssetDownloadStateChanged, object: nil) 43 | notificationCenter.addObserver(self, selector: #selector(handleAssetDownloadProgress(_:)), 44 | name: .AssetDownloadProgress, object: nil) 45 | assetNameLabel.text = asset.name 46 | } 47 | } 48 | } 49 | 50 | // Changes label text and progress bar visibility according to download state 51 | @objc func handleAssetDownloadStateChanged(_ notification: Notification) { 52 | guard let assetStreamName = notification.userInfo![Asset.Keys.name] as? String, 53 | let downloadStateRawValue = notification.userInfo![Asset.Keys.downloadState] as? String, 54 | let downloadState = Asset.DownloadState(rawValue: downloadStateRawValue), 55 | let asset = asset, 56 | asset.name == assetStreamName else { return } 57 | 58 | DispatchQueue.main.async { 59 | switch downloadState { 60 | case .downloading: 61 | self.downloadProgressView.isHidden = false 62 | 63 | if let downloadSelection = notification.userInfo?[Asset.Keys.downloadSelectionDisplayName] as? String { 64 | self.downloadStateLabel.text = "\(downloadState): \(downloadSelection)" 65 | } 66 | 67 | case .downloadedAndSavedToDevice: 68 | self.downloadProgressView.isHidden = true 69 | 70 | case .notDownloaded: 71 | self.downloadStateLabel.text = "\(downloadState)" 72 | self.downloadProgressView.isHidden = true 73 | } 74 | 75 | // Reload the cell to show a fresh state 76 | self.delegate?.assetListTableViewCell(self, downloadStateDidChange: downloadState) 77 | } 78 | } 79 | 80 | // Shows progresss on the progress bar 81 | @objc func handleAssetDownloadProgress(_ notification: Notification) { 82 | guard let assetStreamName = notification.userInfo![Asset.Keys.name] as? String, 83 | asset?.name == assetStreamName else { return } 84 | guard let progress = notification.userInfo![Asset.Keys.percentDownloaded] as? Double else { return } 85 | 86 | self.downloadProgressView.setProgress(Float(progress), animated: true) 87 | } 88 | } 89 | 90 | protocol AssetListTableViewCellDelegate: AnyObject { 91 | func assetListTableViewCell(_ cell: AssetListTableViewCell, downloadStateDidChange newState: Asset.DownloadState) 92 | } 93 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets Table/AssetsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2020 Axinom. All rights reserved. 3 | // 4 | // AssetsViewController provides a list of the assets the sample can play, download, cancel download, and delete. 5 | // To play an item, tap on the tableViewCell. 6 | // To Cancel, Download or Delete an asset, press on the accessory indicator "i" of the cell 7 | // and you will be provided options based on the download state associated with the Asset on the cell. 8 | // You can Download or Cancel/Delete an asset from PlayerViewController that will become visible upon opening an Asset. 9 | // 10 | 11 | import UIKit 12 | import AVFoundation 13 | 14 | let kLocalStreamsFileName = "Streams" 15 | let kShowVideoPlayerSegueId = "showVideoPlayer" 16 | 17 | // Struct representing a Stream parsed from JSON 18 | struct StreamData: Codable, Hashable { 19 | let title: String 20 | let videoUrl: String 21 | let licenseServer: String 22 | let fpsCertificateUrl: String 23 | let licenseToken: String 24 | } 25 | 26 | class AssetsViewController: UIViewController { 27 | 28 | @IBOutlet var assetsTable: UITableView! 29 | 30 | // All parsed streams 31 | var streams = [StreamData]() 32 | 33 | // Asset instances are mapped to StreamData structure and used to fill AssetListTableViewCell 34 | var streamToAssetMap = [StreamData: Asset]() 35 | 36 | // Stream selected by the user 37 | var chosenStream: StreamData? 38 | 39 | // The asset of the stream selected by the user 40 | var chosenAsset: Asset? 41 | 42 | override func viewWillAppear(_ animated: Bool) { 43 | super.viewWillAppear(animated) 44 | 45 | // Read Streams.json 46 | if let localData = self.readLocalStreamsJson() { 47 | // Parse Data read from Streams.json 48 | self.parseStreamsJson(jsonData: localData) 49 | } 50 | } 51 | 52 | // Reads Streams.json 53 | private func readLocalStreamsJson() -> Data? { 54 | do { 55 | if let bundlePath = Bundle.main.path(forResource: kLocalStreamsFileName, 56 | ofType: "json"), 57 | let jsonData = try String(contentsOfFile: bundlePath).data(using: .utf8) { 58 | return jsonData 59 | } 60 | } catch { 61 | print(error.localizedDescription) 62 | } 63 | 64 | return nil 65 | } 66 | 67 | // Parses Streams.json and creates Asset instances for each entry 68 | // Asset instances are mapped to StreamData structure and used to fill AssetListTableViewCell 69 | private func parseStreamsJson(jsonData: Data) { 70 | do { 71 | // Decode Streams.json into StreamData structure 72 | self.streams = try JSONDecoder().decode([StreamData].self, 73 | from: jsonData) 74 | for stream in streams { 75 | guard let url = URL(string: stream.videoUrl) else { 76 | throw ProgramError.missingAssetUrl 77 | } 78 | 79 | let asset: Asset = Asset(name: stream.title, url: url) 80 | 81 | // Used to fill AssetListTableViewCell 82 | streamToAssetMap[stream] = asset 83 | } 84 | } catch { 85 | print(error.localizedDescription) 86 | } 87 | } 88 | 89 | 90 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 91 | // Actions performed before launching Player View Controller 92 | if segue.identifier == kShowVideoPlayerSegueId, 93 | let playerViewController = segue.destination as? PlayerViewController { 94 | 95 | guard let chosenStream = chosenStream else { 96 | return 97 | } 98 | 99 | // Assume that only protected streams will have Licensing Server Url in Streams.json 100 | let isProtectedPlayback = !chosenStream.licenseServer.isEmpty 101 | 102 | // Assigning chosen asset to Player View Contreller 103 | // Is used during download and delete operations 104 | playerViewController.asset = chosenAsset 105 | 106 | // Indicates whether player is opened to play a protected asset 107 | playerViewController.isProtectedPlayback = isProtectedPlayback 108 | 109 | if (isProtectedPlayback) { 110 | // Creting Content Key Session 111 | ContentKeyManager.sharedManager.createContentKeySession() 112 | 113 | // Licensing Service Url 114 | ContentKeyManager.sharedManager.licensingServiceUrl = chosenStream.licenseServer 115 | 116 | // Licensing Token 117 | ContentKeyManager.sharedManager.licensingToken = chosenStream.licenseToken 118 | 119 | // Certificate Url 120 | ContentKeyManager.sharedManager.fpsCertificateUrl = chosenStream.fpsCertificateUrl 121 | } 122 | } 123 | } 124 | } 125 | 126 | extension AssetsViewController: UITableViewDelegate { 127 | func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { 128 | let cell = tableView.cellForRow(at: indexPath) as? AssetListTableViewCell 129 | 130 | chosenStream = streams[indexPath.row] 131 | chosenAsset = cell?.asset 132 | 133 | return indexPath 134 | } 135 | 136 | // Actions for the accessary button on a table cell. Allowing to cancel the ongoing download or delete a saved asset 137 | func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) { 138 | guard let cell = tableView.cellForRow(at: indexPath) as? AssetListTableViewCell, let asset = cell.asset else { return } 139 | 140 | let downloadState = AssetDownloader.sharedDownloader.downloadStateOfAsset(asset: asset) 141 | let alertAction: UIAlertAction 142 | 143 | switch downloadState { 144 | 145 | case .notDownloaded: 146 | return 147 | case .downloading: 148 | alertAction = UIAlertAction(title: "Cancel", style: .default) { _ in 149 | AssetDownloader.sharedDownloader.cancelDownloadOfAsset(asset: asset) 150 | } 151 | 152 | case .downloadedAndSavedToDevice: 153 | alertAction = UIAlertAction(title: "Delete", style: .default) { _ in 154 | AssetDownloader.sharedDownloader.deleteDownloadedAsset(asset: asset) 155 | } 156 | } 157 | 158 | let alertController = UIAlertController(title: asset.name, message: "Select from the following options:", preferredStyle: .actionSheet) 159 | alertController.addAction(alertAction) 160 | alertController.addAction(UIAlertAction(title: "Dismiss", style: .cancel, handler: nil)) 161 | 162 | // iPad 163 | if UIDevice.current.userInterfaceIdiom == .pad { 164 | guard let popoverController = alertController.popoverPresentationController else { 165 | return 166 | } 167 | 168 | popoverController.sourceView = cell 169 | popoverController.sourceRect = cell.bounds 170 | } 171 | 172 | present(alertController, animated: true, completion: nil) 173 | } 174 | } 175 | 176 | extension AssetsViewController: UITableViewDataSource { 177 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 178 | return streams.count 179 | } 180 | 181 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 182 | let cell = tableView.dequeueReusableCell(withIdentifier: AssetListTableViewCell.reuseIdentifier, for: indexPath) 183 | 184 | if let cell = cell as? AssetListTableViewCell { 185 | NotificationCenter.default.removeObserver(cell, 186 | name: .AssetDownloadStateChanged, 187 | object: nil) 188 | 189 | NotificationCenter.default.removeObserver(cell, 190 | name: .AssetDownloadProgress, 191 | object: nil) 192 | 193 | let stream = streams[indexPath.row] 194 | 195 | cell.asset = streamToAssetMap[stream] 196 | cell.delegate = self 197 | } 198 | 199 | return cell 200 | } 201 | } 202 | 203 | extension AssetsViewController: AssetListTableViewCellDelegate { 204 | // Reloads cell to show fresh download state 205 | func assetListTableViewCell(_ cell: AssetListTableViewCell, downloadStateDidChange newState: Asset.DownloadState) { 206 | guard let indexPath = assetsTable.indexPath(for: cell) else { 207 | return 208 | } 209 | assetsTable.reloadRows(at: [indexPath], with: .automatic) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-40.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "Icon-60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "Icon-58.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "Icon-87.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "Icon-80.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "Icon-120.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "Icon-121.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "Icon-180.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "Icon-20.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "Icon-42.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "Icon-29.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "Icon-59.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "Icon-41.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "Icon-81.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "Icon-76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "Icon-152.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "Icon-167.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "Icon-1024.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-1024.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-120.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-121.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-121.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-152.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-167.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-180.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-20.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-29.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-40.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-41.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-41.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-42.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-58.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-59.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-59.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-60.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-76.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-80.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-81.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-81.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/AppIcon.appiconset/Icon-87.png -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 39 | 44 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 138 | 153 | 168 | 183 | 198 | 213 | 228 | 243 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/ConsoleOverlayView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Axinom. All rights reserved. 3 | // 4 | // Custom view for Console Overlay that allows player buttons to be clicked under it. 5 | 6 | import UIKit 7 | 8 | 9 | class ConsoleOverlayView: UIView { 10 | 11 | override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { 12 | for subview in subviews { 13 | if !subview.isHidden && subview.isUserInteractionEnabled && subview.point(inside: convert(point, to: subview), with: event) { 14 | return true 15 | } 16 | } 17 | return false 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Extensions + Utils/LogManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2020 Axinom. All rights reserved. 3 | // 4 | // LogManager class. Writes messages to the TextView. 5 | // 6 | 7 | import Foundation 8 | import UIKit 9 | 10 | class LogManager: NSObject { 11 | 12 | // A singleton instance of LogManager 13 | static let sharedManager = LogManager() 14 | 15 | // Initialization time, used to calculate text entry time 16 | fileprivate let initTime = Date().toMillis()! 17 | 18 | fileprivate var logMessageAll: String = "" 19 | fileprivate var logMessageKeyDelivery: String = "" 20 | fileprivate var logMessagePlayback: String = "" 21 | fileprivate var logMessageDownload: String = "" 22 | 23 | var textView: UITextView? 24 | 25 | var logLevel: LogManagerLevel = LogManagerLevel.LogManagerLevelAll 26 | 27 | enum LogManagerLevel { 28 | case LogManagerLevelAll 29 | case LogManagerLevelKeyDelivery 30 | case LogManagerLevelPlayback 31 | case LogManagerLevelDownload 32 | } 33 | 34 | enum LogMessageType { 35 | case LogMessageTypeAll 36 | case LogMessageTypeKeyDelivery 37 | case LogMessageTypePlayback 38 | case LogMessageTypeDownload 39 | } 40 | 41 | // Prints message to the Console view. 42 | // Also showing the amount of time in ms it took relatively to PlayerViewContorller's init time. 43 | // Dublicates output to Xcode debug console. 44 | func writeToTextView(_ textView: UITextView, _ message: String, _ type: LogMessageType = LogMessageType.LogMessageTypeAll) { 45 | 46 | self.textView = textView 47 | 48 | DispatchQueue.main.async { 49 | let timeDeltaMs: Int64 = Date().toMillis()! - self.initTime 50 | 51 | let dateFormmater = DateFormatter() 52 | dateFormmater.timeZone = TimeZone(identifier: "UTC") 53 | dateFormmater.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS'Z'" 54 | 55 | let dateString = dateFormmater.string(from: Date()) 56 | 57 | self.logMessageAll = NSString(format: "%@%@%dms (%@) %@\n", self.logMessageAll, self.logMessageAll.isEmpty ? "\n" : "", timeDeltaMs, dateString, message) as String 58 | 59 | switch type { 60 | case .LogMessageTypePlayback: 61 | self.logMessagePlayback = NSString(format: "%@%@%dms (%@) %@\n", self.logMessagePlayback, self.logMessagePlayback.isEmpty ? "\n" : "", timeDeltaMs, dateString, message) as String 62 | case .LogMessageTypeKeyDelivery: 63 | self.logMessageKeyDelivery = NSString(format: "%@%@%dms (%@) %@\n", self.logMessageKeyDelivery, self.logMessageKeyDelivery.isEmpty ? "\n" : "", timeDeltaMs, dateString, message) as String 64 | case .LogMessageTypeDownload: 65 | self.logMessageDownload = NSString(format: "%@%@%dms (%@) %@\n", self.logMessageDownload, self.logMessageDownload.isEmpty ? "\n" : "", timeDeltaMs, dateString, message) as String 66 | case .LogMessageTypeAll: 67 | break 68 | // Saving all messages before 69 | } 70 | 71 | self.writeMessageForLevel(self.logLevel) 72 | 73 | print("CONSOLE OUTPUT: \(message)") 74 | } 75 | } 76 | 77 | func writeMessageForLevel(_ level: LogManagerLevel) { 78 | switch level { 79 | case .LogManagerLevelAll: 80 | self.textView!.text = self.logMessageAll 81 | case .LogManagerLevelKeyDelivery: 82 | self.textView!.text = self.logMessageKeyDelivery 83 | case .LogManagerLevelPlayback: 84 | self.textView!.text = self.logMessagePlayback 85 | case .LogManagerLevelDownload: 86 | self.textView!.text = self.logMessageDownload 87 | } 88 | } 89 | 90 | func swithLogLevel(_ level: LogManagerLevel) { 91 | self.writeMessageForLevel(level) 92 | } 93 | 94 | func clear() { 95 | logMessageAll = "" 96 | logMessageKeyDelivery = "" 97 | logMessagePlayback = "" 98 | logMessageDownload = "" 99 | 100 | self.textView?.text = "" 101 | } 102 | } 103 | 104 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Extensions + Utils/Notification.Name.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2020 Axinom. All rights reserved. 3 | // 4 | // Notification.Name 5 | // 6 | 7 | import Foundation 8 | 9 | extension Notification.Name { 10 | /* 11 | The notification that is posted when all the content keys for a given asset have been saved to disk. 12 | */ 13 | static let HasAvailablePersistableContentKey = Notification.Name("ContentKeyDelegateHasAvailablePersistableContentKey") 14 | 15 | // Notification for when download progress has changed. 16 | static let AssetDownloadProgress = Notification.Name(rawValue: "AssetDownloadProgressNotification") 17 | 18 | // Notification for when the download state of an Asset has changed. 19 | static let AssetDownloadStateChanged = Notification.Name(rawValue: "AssetDownloadStateChangedNotification") 20 | 21 | // Notification for when message is sent to player view console 22 | static let ConsoleMessageSent = Notification.Name(rawValue: "ConsoleMessageSentNotification") 23 | 24 | // Notification for when AssetPersistenceManager has completely restored its state. 25 | //static let AssetPersistenceManagerDidRestoreState = Notification.Name(rawValue: "AssetPersistenceManagerDidRestoreStateNotification") 26 | } 27 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Extensions + Utils/Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2020 Axinom. All rights reserved. 3 | // 4 | // Utils 5 | // 6 | 7 | import Foundation 8 | 9 | extension Date { 10 | func toMillis() -> Int64! { 11 | return Int64(self.timeIntervalSince1970 * 1000) 12 | } 13 | } 14 | 15 | extension URLSession { 16 | func synchronousDataTask(urlRequest: URLRequest) -> (data: Data?, response: URLResponse?, error: Error?) { 17 | var data: Data? 18 | var response: URLResponse? 19 | var error: Error? 20 | 21 | let semaphore = DispatchSemaphore(value: 0) 22 | 23 | let dataTask = self.dataTask(with: urlRequest) { 24 | data = $0 25 | response = $1 26 | error = $2 27 | 28 | semaphore.signal() 29 | } 30 | dataTask.resume() 31 | 32 | _ = semaphore.wait(timeout: .distantFuture) 33 | 34 | return (data, response, error) 35 | } 36 | } 37 | 38 | func bytesToHumanReadable(bytes: Double) -> String { 39 | let formatter = ByteCountFormatter() 40 | 41 | if (bytes.isNaN || bytes.isInfinite) { 42 | return "-" 43 | } 44 | 45 | return formatter.string(fromByteCount: Int64(bytes)) + "/s" 46 | } 47 | 48 | enum ProgramError: Error { 49 | case missingApplicationCertificate 50 | case missingApplicationCertificateUrl 51 | case missingAssetUrl 52 | case applicationCertificateRequestFailed 53 | case missingLicensingServiceUrl 54 | case noCKCReturnedByKSM 55 | } 56 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Axinom Drm Sample Player 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | NSAppTransportSecurity 26 | 27 | NSAllowsArbitraryLoads 28 | 29 | 30 | UIApplicationSceneManifest 31 | 32 | UIApplicationSupportsMultipleScenes 33 | 34 | UISceneConfigurations 35 | 36 | UIWindowSceneSessionRoleApplication 37 | 38 | 39 | UISceneConfigurationName 40 | Default Configuration 41 | UISceneDelegateClassName 42 | $(PRODUCT_MODULE_NAME).SceneDelegate 43 | UISceneStoryboardFile 44 | Main 45 | 46 | 47 | 48 | 49 | UIBackgroundModes 50 | 51 | UILaunchStoryboardName 52 | LaunchScreen 53 | UIMainStoryboardFile 54 | Main 55 | UIRequiredDeviceCapabilities 56 | 57 | armv7 58 | 59 | UISupportedInterfaceOrientations 60 | 61 | UIInterfaceOrientationLandscapeLeft 62 | UIInterfaceOrientationLandscapeRight 63 | 64 | UISupportedInterfaceOrientations~ipad 65 | 66 | UIInterfaceOrientationPortrait 67 | UIInterfaceOrientationPortraitUpsideDown 68 | UIInterfaceOrientationLandscapeLeft 69 | UIInterfaceOrientationLandscapeRight 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/PlayerViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2020 Axinom. All rights reserved. 3 | // 4 | // PlayerViewController uses a native AVPlayer as a base and provides a Video Player user interface together with 5 | // capabilities of managing the downloading process, deleting downloaded media together with the 6 | // Content Key associated with an asset. 7 | // 8 | // Togglable Console view allows user to see verbose logging of the steps performed during 9 | // the playback of protected and non-protected assets, FairPlay content protection related activity, 10 | // as well as AVPlayerItem and AVPlayer statuses, buffer events, and Access log 11 | // and Error log events associated with AVPlayerItem. 12 | // Console output can be cleared and copied to the device clipboard. 13 | // 14 | 15 | import UIKit 16 | import AVKit 17 | 18 | class PlayerViewController: UIViewController { 19 | @IBOutlet weak var consoleOverlayView: ConsoleOverlayView! 20 | @IBOutlet weak var consoleTextView: UITextView! 21 | @IBOutlet weak var clearConsoleButton: UIButton! 22 | @IBOutlet weak var copyConsoleButton: UIButton! 23 | @IBOutlet weak var saveDeleteAssetButton: UIButton! 24 | @IBOutlet weak var renewLicenseButton: UIButton! 25 | @IBOutlet weak var showAllMessagesButton: UIButton! 26 | @IBOutlet weak var showDownloadMessagesButton: UIButton! 27 | @IBOutlet weak var showKeyDeliveryMessagesButton: UIButton! 28 | @IBOutlet weak var showPlaybackMessagesButton: UIButton! 29 | 30 | // Current asset 31 | var asset: Asset! 32 | 33 | // Indicates whether player is opened to play protected asset 34 | var isProtectedPlayback: Bool = false 35 | 36 | // Last observed bitrate 37 | fileprivate var lastBitrate:Double = 0 38 | 39 | // Used to caclulate stall duration 40 | fileprivate var stallBeginTime:Int64 = 0 41 | 42 | // Indicates ongoing stall 43 | fileprivate var isStalling = false 44 | 45 | // Asset downloader 46 | fileprivate var downloader: AssetDownloader = AssetDownloader.sharedDownloader 47 | 48 | // Player 49 | @objc fileprivate var player:AVPlayer? = nil 50 | 51 | override func viewDidLoad() { 52 | super.viewDidLoad() 53 | 54 | writeToConsole("Initiating playback of: \(asset.name)", LogManager.LogMessageType.LogMessageTypePlayback) 55 | 56 | // Using downloaded asset, if exists 57 | if let downloadedAsset = downloader.downloadedAsset(withName: asset.name) { 58 | writeToConsole("OFFLINE PLAYBACK", LogManager.LogMessageType.LogMessageTypePlayback) 59 | writeToConsole("Using AVURLAsset from \(String(describing: downloadedAsset.urlAsset?.url)))", LogManager.LogMessageType.LogMessageTypePlayback) 60 | 61 | asset = downloadedAsset 62 | } 63 | 64 | // Using different AVURLAsset to allow simultaneous playback and download 65 | asset.createUrlAsset() 66 | 67 | if (isProtectedPlayback) { 68 | // Making the asset a Content Key Session recepient 69 | asset.addAsContentKeyRecipient() 70 | 71 | // Assigning chosen asset to Content Key Session manager 72 | // Is used to request Persistable Content Keys and writing them to disk 73 | ContentKeyManager.sharedManager.asset = asset 74 | } 75 | 76 | prepareSaveDeleteAssetButton(forState: downloader.downloadStateOfAsset(asset: asset)) 77 | 78 | writeToConsole("Initiating AVPlayer with AVPlayerItem", LogManager.LogMessageType.LogMessageTypePlayback) 79 | 80 | player = AVPlayer(playerItem: AVPlayerItem(asset: asset.urlAsset)) 81 | 82 | writeToConsole("AVPlayer Ready", LogManager.LogMessageType.LogMessageTypePlayback) 83 | 84 | // Observe player and playerItem states as well as NotificationCenter relevant notifications 85 | addObservers() 86 | } 87 | 88 | override func viewWillAppear(_ animated: Bool) { 89 | super.viewWillAppear(animated) 90 | 91 | // UI, Console overlay 92 | let playerFrame = CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height) 93 | let playerViewController: AVPlayerViewController = AVPlayerViewController() 94 | 95 | playerViewController.player = player 96 | playerViewController.view.frame = playerFrame 97 | 98 | addChild(playerViewController) 99 | view.addSubview(playerViewController.view) 100 | playerViewController.didMove(toParent: self) 101 | 102 | view.addSubview(consoleOverlayView) 103 | consoleOverlayView.translatesAutoresizingMaskIntoConstraints = false 104 | NSLayoutConstraint.activate([ 105 | consoleOverlayView.heightAnchor.constraint(equalTo: view.heightAnchor, constant: -150), 106 | consoleOverlayView.widthAnchor.constraint(equalTo: view.widthAnchor), 107 | consoleOverlayView.centerXAnchor.constraint(equalTo: view.centerXAnchor), 108 | consoleOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor) 109 | ]) 110 | } 111 | 112 | // MARK: Observers 113 | // Most of the observed notifications are handed for console logging purpose 114 | // Mandatory notifications are: .HasAvailablePersistableContentKey, .AssetDownloadProgress, .AssetDownloadStateChanged 115 | func addObservers() { 116 | 117 | // [LOGGING] Add observer for player status 118 | addObserver(self, forKeyPath: #keyPath(player.status), options: [.new, .initial], context: nil) 119 | 120 | // [LOGGING] Add observer for playerItem status 121 | addObserver(self, forKeyPath: #keyPath(player.currentItem.status), options: [.new, .initial], context: nil) 122 | 123 | // [LOGGING] Add observer for playerItem buffer 124 | addObserver(self, forKeyPath: #keyPath(player.currentItem.isPlaybackBufferEmpty), options: .new, context: nil) 125 | 126 | // [LOGGING] Add observer for monitoring buffer full event 127 | addObserver(self, forKeyPath: #keyPath(player.currentItem.isPlaybackBufferFull), options: .new, context: nil) 128 | 129 | // [LOGGING] Add observer for monitoring whether the item will likely play through without stalling 130 | addObserver(self, forKeyPath: #keyPath(player.currentItem.isPlaybackLikelyToKeepUp), options: .new, context: nil) 131 | 132 | // [LOGGING] Provides a collection of time ranges for which the player has the media data readily available 133 | addObserver(self, forKeyPath: #keyPath(player.currentItem.loadedTimeRanges), options: .new, context: nil) 134 | 135 | // [LOGGING] Indicates whether output is being obscured because of insufficient external protection 136 | addObserver(self, forKeyPath: #keyPath(player.isOutputObscuredDueToInsufficientExternalProtection), options: .new, context: nil) 137 | 138 | // [LOGGING] Console message arrived 139 | NotificationCenter.default.addObserver(self, selector: #selector(handleConsoleMessageSent(_:)), name: NSNotification.Name.ConsoleMessageSent, object: nil) 140 | 141 | // [LOGGING] Item has failed to play to its end time 142 | NotificationCenter.default.addObserver(self, selector: #selector(itemFailedToPlayToEndTime), name: NSNotification.Name.AVPlayerItemFailedToPlayToEndTime, object: player?.currentItem) 143 | 144 | // [LOGGING] Item has played to its end time 145 | NotificationCenter.default.addObserver(self, selector: #selector(itemDidPlayToEndTime), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: player?.currentItem) 146 | 147 | // [LOGGING] Media did not arrive in time to continue playback 148 | NotificationCenter.default.addObserver(self, selector: #selector(itemPlaybackStalled), name: NSNotification.Name.AVPlayerItemPlaybackStalled, object: player?.currentItem) 149 | 150 | // [LOGGING] A new access log entry has been added 151 | NotificationCenter.default.addObserver(self, selector: #selector(itemNewAccessLogEntry), name: NSNotification.Name.AVPlayerItemNewAccessLogEntry, object: player?.currentItem) 152 | 153 | // [LOGGING] A new error log entry has been added 154 | NotificationCenter.default.addObserver(self, selector: #selector(itemNewErrorLogEntry), name: NSNotification.Name.AVPlayerItemNewErrorLogEntry, object: player?.currentItem) 155 | 156 | // [LOGGING] A media selection group changed its selected option 157 | NotificationCenter.default.addObserver(self, selector: #selector(mediaSelectionDidChange), name: AVPlayerItem.mediaSelectionDidChangeNotification, object: player?.currentItem) 158 | 159 | // [MANDATORY] ContentKey delegate did save a Persistable Content Key 160 | NotificationCenter.default.addObserver(self, selector: #selector(handleContentKeyDelegateHasAvailablePersistableContentKey(notification:)), name: .HasAvailablePersistableContentKey, object: nil) 161 | 162 | // [MANDATORY] State of downloading process is changed 163 | NotificationCenter.default.addObserver(self, selector: #selector(handleAssetDownloadStateChanged(_:)), name: .AssetDownloadStateChanged, object: nil) 164 | 165 | // [MANDATORY] Track asset download progress 166 | NotificationCenter.default.addObserver(self, selector: #selector(handleAssetDownloadProgress(_:)),name: .AssetDownloadProgress, object:nil) 167 | } 168 | 169 | // All observed values are handed for console logging purpose 170 | // [LOGGING] 171 | override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { 172 | 173 | // Player Item Status 174 | if keyPath == #keyPath(player.currentItem.status) { 175 | let status: AVPlayerItem.Status 176 | 177 | // Get the status change from the change dictionary 178 | if let statusNumber = change?[.newKey] as? NSNumber { 179 | status = AVPlayerItem.Status(rawValue: statusNumber.intValue)! 180 | } else { 181 | status = .unknown 182 | } 183 | 184 | // Switch over the status 185 | switch status { 186 | case .readyToPlay: 187 | writeToConsole("Player item is ready to play", LogManager.LogMessageType.LogMessageTypePlayback) 188 | case .failed: 189 | writeToConsole("Player item failed error: \(String(describing: player?.currentItem?.error?.localizedDescription))\n Debug info: \(String(describing: player?.currentItem?.error.debugDescription))", LogManager.LogMessageType.LogMessageTypePlayback) 190 | case .unknown: 191 | writeToConsole("Player item is not yet ready", LogManager.LogMessageType.LogMessageTypePlayback) 192 | @unknown default: 193 | writeToConsole("UNEXPECTED STATUS", LogManager.LogMessageType.LogMessageTypePlayback) 194 | } 195 | } 196 | 197 | // Player Status 198 | if keyPath == #keyPath(player.status) { 199 | let status: AVPlayer.Status 200 | 201 | // Get the status change from the change dictionary 202 | if let statusNumber = change?[.newKey] as? NSNumber { 203 | status = AVPlayer.Status(rawValue: statusNumber.intValue)! 204 | } else { 205 | status = .unknown 206 | } 207 | 208 | // Switch over the status 209 | switch status { 210 | case .readyToPlay: 211 | writeToConsole("Player is ready to play AVPlayerItem instances", LogManager.LogMessageType.LogMessageTypePlayback) 212 | case .failed: 213 | writeToConsole("Player can no longer play AVPlayerItem instances because of an error: \(String(describing: player?.error?.localizedDescription))\n Debug info: \(String(describing: player?.error.debugDescription))", LogManager.LogMessageType.LogMessageTypePlayback) 214 | case .unknown: 215 | writeToConsole("Player is not yet ready", LogManager.LogMessageType.LogMessageTypePlayback) 216 | @unknown default: 217 | writeToConsole("UNEXPECTED STATUS", LogManager.LogMessageType.LogMessageTypePlayback) 218 | } 219 | } 220 | 221 | /* 222 | This property communicates a prediction of playability. Factors considered in this prediction 223 | include I/O throughput and media decode performance. It is possible for playbackLikelyToKeepUp to 224 | indicate NO while the property playbackBufferFull indicates YES. In this event the playback buffer has 225 | reached capacity but there isn't the statistical data to support a prediction that playback is likely to 226 | keep up. It is left to the application programmer to decide to continue media playback or not. 227 | */ 228 | 229 | if keyPath == #keyPath(player.currentItem.isPlaybackBufferEmpty) { 230 | 231 | guard let currentItem = player!.currentItem else { 232 | return 233 | } 234 | 235 | if currentItem.isPlaybackBufferEmpty { 236 | writeToConsole("Data buffer used for playback is empty. Playback will stall or end", LogManager.LogMessageType.LogMessageTypePlayback) 237 | } else { 238 | writeToConsole("Data buffer used for playback is not empty anymore", LogManager.LogMessageType.LogMessageTypePlayback) 239 | } 240 | } 241 | 242 | /* 243 | This property reports that the data buffer used for playback has reach capacity. 244 | Despite the playback buffer reaching capacity there might not exist sufficient statistical 245 | data to support a playbackLikelyToKeepUp prediction of YES. See playbackLikelyToKeepUp above 246 | */ 247 | if keyPath == #keyPath(player.currentItem.isPlaybackBufferFull) { 248 | 249 | guard let currentItem = player!.currentItem else { 250 | return 251 | } 252 | 253 | if currentItem.isPlaybackBufferFull { 254 | writeToConsole("Data buffer used for playback is full", LogManager.LogMessageType.LogMessageTypePlayback) 255 | } else { 256 | writeToConsole("Data buffer used for playback is not full anymore", LogManager.LogMessageType.LogMessageTypePlayback) 257 | } 258 | } 259 | 260 | /* 261 | This property communicates a prediction of playability. Factors considered in this prediction 262 | include I/O throughput and media decode performance. It is possible for playbackLikelyToKeepUp to 263 | indicate NO while the property playbackBufferFull indicates YES. In this event the playback buffer has 264 | reached capacity but there isn't the statistical data to support a prediction that playback is likely to 265 | keep up. It is left to the application programmer to decide to continue media playback or not. 266 | See playbackBufferFull below. 267 | */ 268 | if keyPath == #keyPath(player.currentItem.isPlaybackLikelyToKeepUp) { 269 | guard let currentItem = player!.currentItem else { 270 | return 271 | } 272 | 273 | if currentItem.isPlaybackLikelyToKeepUp { 274 | writeToConsole("Playback will likely to keep up", LogManager.LogMessageType.LogMessageTypePlayback) 275 | 276 | if isStalling { 277 | isStalling = false 278 | let stallDurationMs: Int64 = Date().toMillis()! - stallBeginTime 279 | writeToConsole("Stall took \(stallDurationMs) ms", LogManager.LogMessageType.LogMessageTypePlayback) 280 | } 281 | 282 | } else { 283 | writeToConsole("Playback will likey to fail", LogManager.LogMessageType.LogMessageTypePlayback) 284 | } 285 | } 286 | 287 | if keyPath == #keyPath(player.isOutputObscuredDueToInsufficientExternalProtection) { 288 | if player!.isOutputObscuredDueToInsufficientExternalProtection { 289 | writeToConsole("Output is being obscured because current device configuration does not meet the requirements for protecting the item", LogManager.LogMessageType.LogMessageTypePlayback) 290 | } else { 291 | writeToConsole("OK. Device configuration meets the requirements for protecting the item", LogManager.LogMessageType.LogMessageTypePlayback) 292 | } 293 | } 294 | } 295 | 296 | // [LOGGING] 297 | // Item has failed to play to its end time 298 | @objc func itemFailedToPlayToEndTime(_ notification: Notification) { 299 | let error:Error? = notification.userInfo!["AVPlayerItemFailedToPlayToEndTimeErrorKey"] as? Error 300 | 301 | writeToConsole("Item failed to play to the end. Error: \(String(describing:error?.localizedDescription)), error: \(String(describing: error))", LogManager.LogMessageType.LogMessageTypePlayback) 302 | } 303 | 304 | // Item has played to its end time 305 | // [LOGGING] 306 | @objc func itemDidPlayToEndTime(_ notification: Notification) { 307 | writeToConsole("Item has played to its end time", LogManager.LogMessageType.LogMessageTypePlayback) 308 | } 309 | 310 | // Media did not arrive in time to continue playback 311 | // [LOGGING] 312 | @objc func itemPlaybackStalled(_ notification: Notification) { 313 | isStalling = true 314 | // Used to calculate time delta of the stall which is printed to the Console 315 | stallBeginTime = Date().toMillis()! 316 | 317 | writeToConsole("Stall occured. Media did not arrive in time to continue playback", LogManager.LogMessageType.LogMessageTypePlayback) 318 | } 319 | 320 | // A new access log entry has been added 321 | // [LOGGING] 322 | @objc func itemNewAccessLogEntry(_ notification: Notification) { 323 | 324 | guard let playerItem = notification.object as? AVPlayerItem, 325 | let lastEvent = playerItem.accessLog()?.events.last else { 326 | return 327 | } 328 | 329 | if lastEvent.indicatedBitrate != lastBitrate { 330 | writeToConsole("Bitrate changed to \(bytesToHumanReadable(bytes: lastEvent.indicatedBitrate))", LogManager.LogMessageType.LogMessageTypePlayback) 331 | } 332 | 333 | writeToConsole(""" 334 | \n-------------- NEW PLAYER ACCESS LOG ENTRY -------------- \n \ 335 | URI: \(String(describing: lastEvent.uri)) \n \ 336 | PLAYBACK SESSION ID: \(String(describing: lastEvent.playbackSessionID)) \n \ 337 | PLAYBACK START DATE: \(String(describing: lastEvent.playbackStartDate)) \n \ 338 | PLAYBACK START OFFSET: \(lastEvent.playbackStartOffset) \n \ 339 | PLAYBACK TYPE: \(String(describing: lastEvent.playbackType)) \n \ 340 | INDICATED BITRATE (ADVERTISED BY SERVER): \(bytesToHumanReadable(bytes: lastEvent.indicatedBitrate)) \n \ 341 | OBSERVED BITRATE (ACROSS ALL MEDIA DOWNLOADED): \(bytesToHumanReadable(bytes: lastEvent.observedBitrate)) \n \ 342 | AVERAGE BITRATE REQUIRED TO PLAY THE STREAM (ADVERTISED BY SERVER): \(bytesToHumanReadable(bytes: lastEvent.indicatedAverageBitrate)) \n \ 343 | BYTES TRANSFERRED: \(bytesToHumanReadable(bytes: Double(lastEvent.numberOfBytesTransferred))) \n \ 344 | STARTUP TIME: \(lastEvent.startupTime) \n \ 345 | DURATION WATCHED: \(lastEvent.durationWatched) \n \ 346 | NUMBER OF DROPPED VIDEO FRAMES: \(lastEvent.numberOfDroppedVideoFrames) \n \ 347 | NUMBER OF STALLS: \(lastEvent.numberOfStalls) \n \ 348 | NUMBER OF TIMES DOWNLOADING SEGMENTS TOOK TOO LONG: \(lastEvent.downloadOverdue) \n \ 349 | TOTAL DURATION OF DOWNLOADED SEGMENTS: \(lastEvent.segmentsDownloadedDuration) 350 | """, LogManager.LogMessageType.LogMessageTypePlayback) 351 | } 352 | 353 | // A new error log entry has been added 354 | // [LOGGING] 355 | @objc func itemNewErrorLogEntry(_ notification: Notification) { 356 | 357 | guard let playerItem = notification.object as? AVPlayerItem, 358 | let lastEvent = playerItem.errorLog()?.events.last else { 359 | return 360 | } 361 | 362 | writeToConsole(""" 363 | \n-------------- NEW PLAYER ERROR LOG ENTRY -------------- \n \ 364 | URI: \(String(describing: lastEvent.uri)) \n \ 365 | DATE: \(String(describing: lastEvent.date)) \n \ 366 | SERVER: \(String(describing: lastEvent.serverAddress)) \n \ 367 | ERROR STATUS CODE: \(String(describing: lastEvent.errorStatusCode)) \n \ 368 | ERROR DOMAIN: \(String(describing: lastEvent.errorDomain)) \n \ 369 | ERROR COMMENT: \(String(describing: lastEvent.errorComment)) \n \ 370 | PLAYBACK SESSION ID: \(String(describing: lastEvent.playbackSessionID)) 371 | """, LogManager.LogMessageType.LogMessageTypePlayback) 372 | } 373 | 374 | // A media selection group changed its selected option 375 | // [LOGGING] 376 | @objc func mediaSelectionDidChange(_ notification: Notification) { 377 | writeToConsole("A media selection group changed its selected option", LogManager.LogMessageType.LogMessageTypePlayback) 378 | } 379 | 380 | // Begin with stream download process after .ContentKeyDelegateHasAvailablePersistableContentKey notification is received 381 | @objc func handleContentKeyDelegateHasAvailablePersistableContentKey(notification: Notification) { 382 | writeToConsole("Persistable Content Key is now available", LogManager.LogMessageType.LogMessageTypeKeyDelivery) 383 | 384 | // guard let assetName = notification.userInfo?["name"] as? String, 385 | // let asset = ContentKeyManager.pendingContentKeyRequests.removeValue(forKey: assetName) else { 386 | // return 387 | // } 388 | 389 | // Initiate download if not already downloaded 390 | if downloader.downloadStateOfAsset(asset: asset) != Asset.DownloadState.downloadedAndSavedToDevice && ContentKeyManager.sharedManager.downloadRequestedByUser { 391 | downloadStream() 392 | ContentKeyManager.sharedManager.downloadRequestedByUser = false 393 | } 394 | } 395 | 396 | // Reacting to .ConsoleMessageSent notification posted by ContentKeyManager 397 | // [LOGGING] 398 | @objc func handleConsoleMessageSent(_ notification: Notification) { 399 | guard let message = notification.userInfo!["message"] as? String else { 400 | return 401 | } 402 | writeToConsole(message, LogManager.LogMessageType.LogMessageTypeKeyDelivery) 403 | } 404 | 405 | // Reacting to asset download state changes 406 | @objc func handleAssetDownloadStateChanged(_ notification: Notification) { 407 | DispatchQueue.main.async { 408 | 409 | guard let downloadStateRawValue = notification.userInfo![Asset.Keys.downloadState] as? String, 410 | let downloadState = Asset.DownloadState(rawValue: downloadStateRawValue) 411 | else { 412 | self.writeToConsole("Download state missing", LogManager.LogMessageType.LogMessageTypeDownload) 413 | return 414 | } 415 | 416 | switch downloadState { 417 | case .downloading: 418 | var downloadSelectionDisplayName:String 419 | 420 | // Showing which media selection is being downloaded 421 | if let downloadSelection = notification.userInfo?[Asset.Keys.downloadSelectionDisplayName] as? String { 422 | downloadSelectionDisplayName = downloadSelection 423 | self.writeToConsole("DOWNLOADING \(String(describing: downloadSelectionDisplayName))", LogManager.LogMessageType.LogMessageTypeDownload) 424 | } 425 | case .downloadedAndSavedToDevice: 426 | self.writeToConsole("FINISHED DOWNLOADING", LogManager.LogMessageType.LogMessageTypeDownload) 427 | case .notDownloaded: 428 | self.writeToConsole("ASSET NOT DOWNLOADED", LogManager.LogMessageType.LogMessageTypeDownload) 429 | } 430 | 431 | self.prepareSaveDeleteAssetButton(forState: downloadState) 432 | } 433 | } 434 | 435 | // Download button can have three different label text variants: "DELETE", "SAVE", "CANCEL" 436 | // Choosing the right one according to asset download state 437 | func prepareSaveDeleteAssetButton(forState state: Asset.DownloadState) { 438 | switch state { 439 | case .downloadedAndSavedToDevice: 440 | self.saveDeleteAssetButton.setTitle("DELETE", for: UIControl.State.normal) 441 | case .notDownloaded: 442 | self.saveDeleteAssetButton.setTitle("SAVE", for: UIControl.State.normal) 443 | case .downloading: 444 | self.saveDeleteAssetButton.setTitle("CANCEL", for: UIControl.State.normal) 445 | } 446 | } 447 | 448 | // Shows Download progress in % 449 | // [LOGGING] 450 | @objc func handleAssetDownloadProgress(_ notification: Notification) { 451 | guard let progress = notification.userInfo![Asset.Keys.percentDownloaded] as? Double, 452 | let assetName = notification.userInfo![Asset.Keys.name] as? String, 453 | assetName == asset.name else { return } 454 | 455 | let humanReadableProgress = Double(round(1000 * progress) / 10) 456 | 457 | writeToConsole("DOWNLOADING PROGRESS of \(assetName) : \(humanReadableProgress)%", LogManager.LogMessageType.LogMessageTypeDownload) 458 | } 459 | 460 | func downloadStream() { 461 | writeToConsole("Initiating stream download", LogManager.LogMessageType.LogMessageTypeDownload) 462 | downloader.download(asset: asset) 463 | } 464 | 465 | // MARK: Console 466 | 467 | // Prints message to the Console view. 468 | // [LOGGING] 469 | func writeToConsole(_ message: String, _ messageType: LogManager.LogMessageType = LogManager.LogMessageType.LogMessageTypeAll) { 470 | LogManager.sharedManager.writeToTextView(self.consoleTextView, message, messageType) 471 | } 472 | 473 | // Toggles the Console visibility 474 | // [LOGGING] 475 | @IBAction func showConsole(_ sender: Any) { 476 | let hide = !consoleTextView.isHidden 477 | 478 | consoleTextView.isHidden = hide 479 | copyConsoleButton.isHidden = hide 480 | clearConsoleButton.isHidden = hide 481 | showAllMessagesButton.isHidden = hide 482 | showDownloadMessagesButton.isHidden = hide 483 | showKeyDeliveryMessagesButton.isHidden = hide 484 | showPlaybackMessagesButton.isHidden = hide 485 | } 486 | 487 | @IBAction func saveOrDeleteAsset(_ sender: Any) { 488 | switch downloader.downloadStateOfAsset(asset: asset) { 489 | case .notDownloaded: 490 | 491 | // Using different AVURLAsset to allow simultaneous playback and download 492 | asset.createUrlAsset() 493 | 494 | if isProtectedPlayback { 495 | // Create a new Content Key Session for downloading an asset 496 | ContentKeyManager.sharedManager.createContentKeySession() 497 | 498 | // Making the asset a Content Key Session recipient 499 | asset.addAsContentKeyRecipient() 500 | 501 | // This will tell ContenyKeyManager to initiate Persistable Content Key Request 502 | ContentKeyManager.sharedManager.downloadRequestedByUser = true 503 | 504 | // Initiate Persistable Key Request 505 | ContentKeyManager.sharedManager.requestPersistableContentKeys(forAsset: asset) 506 | 507 | // The download will start after handlePersistableContentKeyRequest signals that the key has been downloaded 508 | } else { 509 | // Download the stream 510 | downloadStream() 511 | } 512 | case .downloading : 513 | if isProtectedPlayback { 514 | writeToConsole("Cancelling download of \(String(describing: asset.name))", LogManager.LogMessageType.LogMessageTypeDownload) 515 | 516 | // Remove Content Key from the device 517 | ContentKeyManager.sharedManager.deleteAllPeristableContentKeys(forAsset: asset) 518 | } 519 | // Cancel current asset downloading process 520 | downloader.cancelDownloadOfAsset(asset: asset) 521 | case .downloadedAndSavedToDevice: 522 | if isProtectedPlayback { 523 | writeToConsole("Deleting download of \(String(describing: asset.name))", LogManager.LogMessageType.LogMessageTypeDownload) 524 | 525 | // Remove Content Key from the device 526 | ContentKeyManager.sharedManager.deleteAllPeristableContentKeys(forAsset: asset) 527 | } 528 | // Remove downloaded stream from the device 529 | downloader.deleteDownloadedAsset(asset: asset) 530 | } 531 | } 532 | 533 | // Renews the license 534 | @IBAction func renewLicense(_ sender: Any) { 535 | if (ContentKeyManager.sharedManager.contentKeySession != nil) { 536 | NSLog("Trying to renew license") 537 | ContentKeyManager.sharedManager.contentKeySession.renewExpiringResponseData(for: ContentKeyManager.sharedManager.contentKeyRequest) 538 | } else { 539 | NSLog("Can't renew license, Content Key Session does not exist") 540 | } 541 | } 542 | 543 | // Copies Console text to device's clipboard 544 | // [LOGGING] 545 | @IBAction func copyConsoleText(_ sender: Any) { 546 | let pasteBoard = UIPasteboard.general 547 | pasteBoard.string = consoleTextView.text 548 | } 549 | 550 | // Cleans the Console 551 | // [LOGGING] 552 | @IBAction func clearConsoleText(_ sender: Any) { 553 | LogManager.sharedManager.clear() 554 | } 555 | 556 | // Shows all messages in the Console 557 | // [LOGGING] 558 | @IBAction func showAllLogMessages(_ sender: Any) { 559 | LogManager.sharedManager.swithLogLevel(LogManager.LogManagerLevel.LogManagerLevelAll) 560 | } 561 | 562 | // Shows only playback related messages in the Console 563 | // [LOGGING] 564 | @IBAction func showPlaybackLog(_ sender: Any) { 565 | LogManager.sharedManager.swithLogLevel(LogManager.LogManagerLevel.LogManagerLevelPlayback) 566 | } 567 | 568 | // Shows only key delivery messages in the Console 569 | // [LOGGING] 570 | @IBAction func showKeyDeliveryLog(_ sender: Any) { 571 | LogManager.sharedManager.swithLogLevel(LogManager.LogManagerLevel.LogManagerLevelKeyDelivery) 572 | } 573 | 574 | // Shows only downloading related messages in the Console 575 | // [LOGGING] 576 | @IBAction func showDownloadLog(_ sender: Any) { 577 | LogManager.sharedManager.swithLogLevel(LogManager.LogManagerLevel.LogManagerLevelDownload) 578 | } 579 | 580 | deinit { 581 | LogManager.sharedManager.clear() 582 | NotificationCenter.default.removeObserver(self) 583 | } 584 | } 585 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2020 Axinom. All rights reserved. 3 | // 4 | // SceneDelegate is the SceneDelegate for this sample. No additional work performed in this class. 5 | // 6 | 7 | import UIKit 8 | 9 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 10 | 11 | var window: UIWindow? 12 | 13 | 14 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 15 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 16 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 17 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 18 | guard let _ = (scene as? UIWindowScene) else { return } 19 | } 20 | 21 | func sceneDidDisconnect(_ scene: UIScene) { 22 | // Called as the scene is being released by the system. 23 | // This occurs shortly after the scene enters the background, or when its session is discarded. 24 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 25 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). 26 | } 27 | 28 | func sceneDidBecomeActive(_ scene: UIScene) { 29 | // Called when the scene has moved from an inactive state to an active state. 30 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 31 | } 32 | 33 | func sceneWillResignActive(_ scene: UIScene) { 34 | // Called when the scene will move from an active state to an inactive state. 35 | // This may occur due to temporary interruptions (ex. an incoming phone call). 36 | } 37 | 38 | func sceneWillEnterForeground(_ scene: UIScene) { 39 | // Called as the scene transitions from the background to the foreground. 40 | // Use this method to undo the changes made on entering the background. 41 | } 42 | 43 | func sceneDidEnterBackground(_ scene: UIScene) { 44 | // Called as the scene transitions from the foreground to the background. 45 | // Use this method to save data, release shared resources, and store enough scene-specific state information 46 | // to restore the scene back to its current state. 47 | } 48 | 49 | 50 | } 51 | 52 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/AxinomDrmSamplePlayer/Streams.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Apple demo video: Basic stream - Clear", 4 | 5 | "videoUrl": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8", 6 | 7 | "licenseServer": "", 8 | 9 | "fpsCertificateUrl": "", 10 | 11 | "licenseToken": "" 12 | }, 13 | { 14 | "title": "Apple demo video: Advanced Stream - Clear", 15 | 16 | "videoUrl": "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8", 17 | 18 | "licenseServer": "", 19 | 20 | "fpsCertificateUrl": "", 21 | 22 | "licenseToken": "" 23 | }, 24 | { 25 | "title": "Axinom demo video - single key (HLS; cbcs)", 26 | 27 | "videoUrl": "https://media.axprod.net/VTB/DrmQuickStart/AxinomDemoVideo-SingleKey/Encrypted_Cbcs/Manifest.m3u8", 28 | 29 | "licenseServer": "https://drm-fairplay-licensing.axtest.net/AcquireLicense", 30 | 31 | "fpsCertificateUrl": "https://vtb.axinom.com/FPScert/fairplay.cer", 32 | 33 | "licenseToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ2ZXJzaW9uIjoxLCJjb21fa2V5X2lkIjoiNjllNTQwODgtZTllMC00NTMwLThjMWEtMWViNmRjZDBkMTRlIiwibWVzc2FnZSI6eyJ2ZXJzaW9uIjoyLCJ0eXBlIjoiZW50aXRsZW1lbnRfbWVzc2FnZSIsImxpY2Vuc2UiOnsiYWxsb3dfcGVyc2lzdGVuY2UiOnRydWV9LCJjb250ZW50X2tleXNfc291cmNlIjp7ImlubGluZSI6W3siaWQiOiIyMTFhYzFkYy1jOGEyLTQ1NzUtYmFmNy1mYTRiYTU2YzM4YWMiLCJ1c2FnZV9wb2xpY3kiOiJUaGVPbmVQb2xpY3kifV19LCJjb250ZW50X2tleV91c2FnZV9wb2xpY2llcyI6W3sibmFtZSI6IlRoZU9uZVBvbGljeSIsInBsYXlyZWFkeSI6eyJwbGF5X2VuYWJsZXJzIjpbIjc4NjYyN0Q4LUMyQTYtNDRCRS04Rjg4LTA4QUUyNTVCMDFBNyJdfX1dfX0.D9FM9sbTFxBmcCOC8yMHrEtTwm0zy6ejZUCrlJbHz_U" 34 | }, 35 | { 36 | "title": "Axinom demo video - multikey (HLS; cbcs)", 37 | 38 | "videoUrl": "https://media.axprod.net/VTB/DrmQuickStart/AxinomDemoVideo-MultiKey/Encrypted_Cbcs/Manifest.m3u8", 39 | 40 | "licenseServer": "https://drm-fairplay-licensing.axtest.net/AcquireLicense", 41 | 42 | "fpsCertificateUrl": "https://vtb.axinom.com/FPScert/fairplay.cer", 43 | 44 | "licenseToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ2ZXJzaW9uIjoxLCJjb21fa2V5X2lkIjoiNjllNTQwODgtZTllMC00NTMwLThjMWEtMWViNmRjZDBkMTRlIiwibWVzc2FnZSI6eyJ2ZXJzaW9uIjoyLCJ0eXBlIjoiZW50aXRsZW1lbnRfbWVzc2FnZSIsImxpY2Vuc2UiOnsiYWxsb3dfcGVyc2lzdGVuY2UiOnRydWV9LCJjb250ZW50X2tleXNfc291cmNlIjp7ImlubGluZSI6W3siaWQiOiJmM2Q1ODhjNy1jMTdhLTQwMzMtOTAzNS04ZGIzMTczOTBiZTYiLCJ1c2FnZV9wb2xpY3kiOiJUaGVPbmVQb2xpY3kifSx7ImlkIjoiNDRiMThhMzItNmQzNi00OTlkLThiOTMtYTIwZjk0OGFjNWYyIiwidXNhZ2VfcG9saWN5IjoiVGhlT25lUG9saWN5In0seyJpZCI6ImFlNmU4N2UyLTNjM2MtNDZkMS04ZTlkLWVmNGM0NjFkNDY4MSIsInVzYWdlX3BvbGljeSI6IlRoZU9uZVBvbGljeSJ9XX0sImNvbnRlbnRfa2V5X3VzYWdlX3BvbGljaWVzIjpbeyJuYW1lIjoiVGhlT25lUG9saWN5IiwicGxheXJlYWR5Ijp7InBsYXlfZW5hYmxlcnMiOlsiNzg2NjI3RDgtQzJBNi00NEJFLThGODgtMDhBRTI1NUIwMUE3Il19fV19fQ.DpwBd1ax4Z7P0cCOZ7ZJMotqVWfLFCj2DYdH37xjGxM" 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/Managers/AssetDownloader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2020 Axinom. All rights reserved. 3 | // 4 | // AssetDownloader demonstrates how to manage the downloading of HLS streams. 5 | // It includes APIs for starting and canceling downloads, 6 | // deleting existing assets of the user's device, and monitoring the download progress and status. 7 | // 8 | 9 | import Foundation 10 | import AVFoundation 11 | 12 | class AssetDownloader: NSObject, AVAssetDownloadDelegate { 13 | 14 | // A singleton instance of AssetDownloader 15 | static let sharedDownloader = AssetDownloader() 16 | 17 | // The AVAssetDownloadURLSession to use for managing AVAssetDownloadTasks 18 | fileprivate var assetDownloadURLSession: AVAssetDownloadURLSession! 19 | 20 | // Internal map of AVAggregateAssetDownloadTask to its corresponding Asset 21 | fileprivate var activeDownloadsMap = [AVAggregateAssetDownloadTask: Asset]() 22 | 23 | // Internal map of AVAggregateAssetDownloadTask to download URL 24 | fileprivate var willDownloadToUrlMap = [AVAggregateAssetDownloadTask: URL]() 25 | 26 | override init() { 27 | super.init() 28 | 29 | if assetDownloadURLSession == nil { 30 | print("DOWNLOADER: Will create AVAssetDownloadURLSession") 31 | 32 | // Create the configuration for the AVAssetDownloadURLSession 33 | let backgroundConfiguration = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background") 34 | 35 | // Avoid OS scheduling the background request transfers due to battery or performance 36 | backgroundConfiguration.isDiscretionary = false 37 | 38 | // Makes the TCP sockets open even when the app is locked or suspended 39 | backgroundConfiguration.shouldUseExtendedBackgroundIdleMode = true 40 | 41 | // Create the AVAssetDownloadURLSession using the configuration 42 | assetDownloadURLSession = 43 | AVAssetDownloadURLSession(configuration: backgroundConfiguration, 44 | assetDownloadDelegate: self, delegateQueue: OperationQueue.main) 45 | } else { 46 | print("DOWNLOADER: AVAssetDownloadURLSession already exists, will reuse") 47 | } 48 | } 49 | 50 | func download(asset: Asset) { 51 | print("DOWNLOADER: Download") 52 | 53 | guard let urlAsset = asset.urlAsset else { 54 | print("DOWNLOADER: No AVURLAsset supplied") 55 | return 56 | } 57 | 58 | // Get the default media selections for the asset's media selection groups 59 | let preferredMediaSelection = urlAsset.preferredMediaSelection 60 | 61 | /* 62 | Creates and initializes an AVAggregateAssetDownloadTask to download multiple AVMediaSelections 63 | on an AVURLAsset. 64 | 65 | For the initial download, we ask the URLSession for an AVAssetDownloadTask with a minimum bitrate 66 | corresponding with one of the lower bitrate variants in the asset. 67 | */ 68 | guard let task = assetDownloadURLSession.aggregateAssetDownloadTask(with: urlAsset, 69 | mediaSelections: [preferredMediaSelection], 70 | assetTitle: asset.name, 71 | assetArtworkData: nil, 72 | options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_000]) 73 | else { 74 | print("DOWNLOADER: Failed to create AVAggregateAssetDownloadTask") 75 | return 76 | } 77 | 78 | if activeDownloadsMap[task] != nil { 79 | print("DOWNLOADER: Asset is already being downloaded") 80 | } else { 81 | 82 | // Map active task to asset 83 | activeDownloadsMap[task] = asset 84 | 85 | task.taskDescription = asset.name 86 | task.resume() 87 | 88 | // Prepare the basic userInfo dictionary that will be posted as part of our notification 89 | var userInfo = [String: Any]() 90 | userInfo[Asset.Keys.name] = asset.name 91 | userInfo[Asset.Keys.downloadState] = Asset.DownloadState.downloading.rawValue 92 | userInfo[Asset.Keys.downloadSelectionDisplayName] = displayNamesForSelectedMediaOptions(preferredMediaSelection) 93 | 94 | NotificationCenter.default.post(name: .AssetDownloadStateChanged, object: nil, userInfo: userInfo) 95 | } 96 | } 97 | 98 | // Returns an Asset pointing to a file on disk if it exists 99 | func downloadedAsset(withName name: String) -> Asset? { 100 | let userDefaults = UserDefaults.standard 101 | 102 | guard let localFileLocation = userDefaults.value(forKey: name) as? Data else { return nil } 103 | 104 | var bookmarkDataIsStale = false 105 | do { 106 | let url = try URL(resolvingBookmarkData: localFileLocation, 107 | bookmarkDataIsStale: &bookmarkDataIsStale) 108 | 109 | if bookmarkDataIsStale { 110 | fatalError("Bookmark data is stale!") 111 | } 112 | 113 | // Create an asset that will be used during the playback 114 | let asset = Asset(name: name, url: url) 115 | 116 | return asset 117 | } catch { 118 | fatalError("Failed to create URL from bookmark with error: \(error)") 119 | } 120 | } 121 | 122 | // Returns the current download state for a given Asset 123 | func downloadStateOfAsset(asset: Asset) -> Asset.DownloadState { 124 | // Check if there is a file URL stored for this asset 125 | if let localFileLocation = downloadedAsset(withName: asset.name)?.urlAsset?.url { 126 | // Check if the file exists on disk 127 | if FileManager.default.fileExists(atPath: localFileLocation.path) { 128 | 129 | print("DOWNLOADER: downloadState() \(Asset.DownloadState.downloadedAndSavedToDevice.rawValue)") 130 | 131 | return .downloadedAndSavedToDevice 132 | } 133 | } 134 | 135 | // Check if there are any active downloads in flight 136 | for (_, assetValue) in activeDownloadsMap where asset.name == assetValue.name { 137 | print("DOWNLOADER: downloadState() \(Asset.DownloadState.downloading.rawValue)") 138 | 139 | return .downloading 140 | } 141 | 142 | print("DOWNLOADER: downloadState() \(Asset.DownloadState.notDownloaded.rawValue)") 143 | 144 | return .notDownloaded 145 | } 146 | 147 | // Deletes an Asset from the device if possible 148 | func deleteDownloadedAsset(asset: Asset) { 149 | let userDefaults = UserDefaults.standard 150 | 151 | do { 152 | if let localFileLocation = downloadedAsset(withName: asset.name)?.urlAsset?.url { 153 | try FileManager.default.removeItem(at: localFileLocation) 154 | 155 | userDefaults.removeObject(forKey: asset.name) 156 | 157 | print("DOWNLOADER: Deleting downloaded: \(asset.name)") 158 | 159 | // Prepare the basic userInfo dictionary that will be posted as part of our notification 160 | var userInfo = [String: Any]() 161 | userInfo[Asset.Keys.name] = asset.name 162 | userInfo[Asset.Keys.downloadState] = Asset.DownloadState.notDownloaded.rawValue 163 | NotificationCenter.default.post(name: .AssetDownloadStateChanged, object: nil, userInfo: userInfo) 164 | } 165 | } catch { 166 | print("DOWNLOADER: An error occured deleting the file: \(error)") 167 | } 168 | } 169 | 170 | // Canceles the download task 171 | func cancelDownloadOfAsset(asset: Asset) { 172 | var task: AVAggregateAssetDownloadTask? 173 | 174 | for (taskKey, assetVal) in activeDownloadsMap where asset.name == assetVal.name { 175 | task = taskKey 176 | print("DOWNLOADER: Cancelling download of \(String(describing: task?.taskDescription))") 177 | break 178 | } 179 | 180 | task?.cancel() 181 | } 182 | 183 | // Return the display names for the media selection options that are currently selected in the specified group 184 | func displayNamesForSelectedMediaOptions(_ mediaSelection: AVMediaSelection) -> String { 185 | 186 | var displayNames = "" 187 | 188 | guard let asset = mediaSelection.asset else { 189 | return displayNames 190 | } 191 | 192 | // Iterate over every media characteristic in the asset in which a media selection option is available. 193 | for mediaCharacteristic in asset.availableMediaCharacteristicsWithMediaSelectionOptions { 194 | /* 195 | Obtain the AVMediaSelectionGroup object that contains one or more options with the 196 | specified media characteristic, then get the media selection option that's currently 197 | selected in the specified group. 198 | */ 199 | guard let mediaSelectionGroup = 200 | asset.mediaSelectionGroup(forMediaCharacteristic: mediaCharacteristic), 201 | let option = mediaSelection.selectedMediaOption(in: mediaSelectionGroup) else { continue } 202 | 203 | // Obtain the display string for the media selection option. 204 | if displayNames.isEmpty { 205 | displayNames += " " + option.displayName 206 | } else { 207 | displayNames += ", " + option.displayName 208 | } 209 | } 210 | 211 | return displayNames 212 | } 213 | 214 | // MARK: URLSessionTaskDelegate 215 | 216 | // Tells the delegate that the task finished transferring data 217 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 218 | let userDefaults = UserDefaults.standard 219 | 220 | print("DOWNLOADER: Downloading did complete") 221 | 222 | /* 223 | This is the ideal place to begin downloading additional media selections 224 | once the asset itself has finished downloading. 225 | */ 226 | guard let task = task as? AVAggregateAssetDownloadTask, 227 | let asset = activeDownloadsMap.removeValue(forKey: task) else { return } 228 | 229 | guard let downloadURL = willDownloadToUrlMap.removeValue(forKey: task) else { return } 230 | 231 | // Prepare the basic userInfo dictionary that will be posted as part of our notification 232 | var userInfo = [String: Any]() 233 | userInfo[Asset.Keys.name] = asset.name 234 | 235 | if let error = error as NSError? { 236 | switch (error.domain, error.code) { 237 | case (NSURLErrorDomain, NSURLErrorCancelled): 238 | 239 | print("DOWNLOADER: Downloading was cancelled") 240 | 241 | userInfo[Asset.Keys.downloadState] = Asset.DownloadState.notDownloaded.rawValue 242 | 243 | /* 244 | This task was canceled, you should perform cleanup using the 245 | URL saved from AVAssetDownloadDelegate.urlSession(_:assetDownloadTask:didFinishDownloadingTo:). 246 | */ 247 | guard let localFileLocation = downloadedAsset(withName: asset.name)?.urlAsset?.url else { 248 | NotificationCenter.default.post(name: .AssetDownloadStateChanged, object: nil, userInfo: userInfo) 249 | return 250 | } 251 | 252 | do { 253 | try FileManager.default.removeItem(at: localFileLocation) 254 | 255 | userDefaults.removeObject(forKey: asset.name) 256 | } catch { 257 | print("DOWNLOADER: An error occured trying to delete the contents on disk for \(asset.name): \(error)") 258 | } 259 | 260 | case (NSURLErrorDomain, NSURLErrorUnknown): 261 | print("DOWNLOADER: Downloading HLS streams is not supported in the simulator.") 262 | 263 | default: 264 | print("DOWNLOADER: An unexpected error occured \(error.domain)") 265 | } 266 | } else { 267 | do { 268 | let bookmark = try downloadURL.bookmarkData() 269 | 270 | userDefaults.set(bookmark, forKey: asset.name) 271 | } catch { 272 | print("DOWNLOADER: Failed to create bookmarkData for download URL.") 273 | } 274 | 275 | print("DOWNLOADER: Downloading completed with success") 276 | 277 | userInfo[Asset.Keys.downloadState] = Asset.DownloadState.downloadedAndSavedToDevice.rawValue 278 | userInfo[Asset.Keys.downloadSelectionDisplayName] = "" 279 | } 280 | 281 | NotificationCenter.default.post(name: .AssetDownloadStateChanged, object: nil, userInfo: userInfo) 282 | } 283 | 284 | // MARK: AVAssetDownloadDelegate 285 | 286 | // Asks the delegate for the location this asset will be downloaded to 287 | func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, 288 | willDownloadTo location: URL) { 289 | 290 | print("DOWNLOADER: will download to location: \(location)") 291 | 292 | /* 293 | This delegate callback should only be used to save the location URL 294 | somewhere in your application. Any additional work should be done in 295 | `URLSessionTaskDelegate.urlSession(_:task:didCompleteWithError:)`. 296 | */ 297 | 298 | willDownloadToUrlMap[aggregateAssetDownloadTask] = location 299 | } 300 | 301 | // Method called when a child AVAssetDownloadTask completes for each media selection. 302 | func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, 303 | didCompleteFor mediaSelection: AVMediaSelection) { 304 | 305 | print("DOWNLOADER: Done with mediaSelection: \(mediaSelection)") 306 | } 307 | 308 | // Method to adopt to subscribe to progress updates of an AVAggregateAssetDownloadTask 309 | func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, 310 | didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], 311 | timeRangeExpectedToLoad: CMTimeRange, for mediaSelection: AVMediaSelection) { 312 | 313 | // This delegate callback should be used to provide download progress for your AVAssetDownloadTask 314 | var percentComplete = 0.0 315 | for value in loadedTimeRanges { 316 | let loadedTimeRange: CMTimeRange = value.timeRangeValue 317 | percentComplete += 318 | CMTimeGetSeconds(loadedTimeRange.duration) / CMTimeGetSeconds(timeRangeExpectedToLoad.duration) 319 | } 320 | 321 | // Prepare the basic userInfo dictionary that will be posted as part of our notification 322 | var userInfo = [String: Any]() 323 | userInfo[Asset.Keys.name] = aggregateAssetDownloadTask.taskDescription 324 | userInfo[Asset.Keys.percentDownloaded] = percentComplete 325 | 326 | NotificationCenter.default.post(name: .AssetDownloadProgress, object: nil, userInfo: userInfo) 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /AxinomDrmSamplePlayer/Managers/ContentKeyManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2020 Axinom. All rights reserved. 3 | // 4 | // The ContentKeyManager class configures the instance of AVContentKeySession to use for requesting content keys 5 | // securely for playback or offline use. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | 11 | class ContentKeyManager: NSObject, AVContentKeySessionDelegate { 12 | 13 | // Certificate Url 14 | var fpsCertificateUrl: String = "" 15 | 16 | // Licensing Service Url 17 | var licensingServiceUrl: String = "" 18 | 19 | // Licensing Token 20 | var licensingToken: String = "" 21 | 22 | // Current asset 23 | var asset: Asset! 24 | 25 | // A singleton instance of ContentKeyManager 26 | static let sharedManager = ContentKeyManager() 27 | 28 | // Content Key session 29 | var contentKeySession: AVContentKeySession! 30 | 31 | // Content Key request 32 | var contentKeyRequest: AVContentKeyRequest! 33 | 34 | // Indicates that user requested download action 35 | var downloadRequestedByUser: Bool = false 36 | 37 | // Certificate data 38 | fileprivate var fpsCertificate:Data! 39 | 40 | // A set containing the currently pending content key identifiers associated with persistable content key requests that have not been completed. 41 | var pendingPersistableContentKeyIdentifiers = Set() 42 | 43 | // The directory that is used to save persistable content keys 44 | lazy var contentKeyDirectory: URL = { 45 | guard let documentPath = 46 | NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first else { 47 | fatalError("Unable to determine library URL") 48 | } 49 | 50 | let documentURL = URL(fileURLWithPath: documentPath) 51 | 52 | let contentKeyDirectory = documentURL.appendingPathComponent(".keys", isDirectory: true) 53 | 54 | if !FileManager.default.fileExists(atPath: contentKeyDirectory.path, isDirectory: nil) { 55 | do { 56 | try FileManager.default.createDirectory(at: contentKeyDirectory, 57 | withIntermediateDirectories: false, 58 | attributes: nil) 59 | } catch { 60 | fatalError("Unable to create directory for content keys at path: \(contentKeyDirectory.path)") 61 | } 62 | } 63 | 64 | return contentKeyDirectory 65 | }() 66 | 67 | override init() { 68 | super.init() 69 | } 70 | 71 | // Creates Content Key Session 72 | func createContentKeySession() { 73 | print("Creating new AVContentKeySession") 74 | contentKeySession = AVContentKeySession(keySystem: .fairPlayStreaming) 75 | contentKeySession.setDelegate(self, queue: DispatchQueue(label: "\(Bundle.main.bundleIdentifier!).ContentKeyDelegateQueue")) 76 | } 77 | 78 | // Sends message to Console of PlayerViewController 79 | func postToConsole(_ message: String) { 80 | // Prepare the basic userInfo dictionary that will be posted as part of our notification 81 | var userInfo = [String: Any]() 82 | userInfo["message"] = message 83 | 84 | NotificationCenter.default.post(name: .ConsoleMessageSent, object: nil, userInfo: userInfo) 85 | } 86 | 87 | // MARK: Online key retrival 88 | 89 | /* 90 | The following delegate callback gets called when the client initiates a key request or AVFoundation 91 | determines that the content is encrypted based on the playlist the client provided when it requests playback. 92 | */ 93 | func contentKeySession(_ session: AVContentKeySession, didProvide keyRequest: AVContentKeyRequest) { 94 | self.postToConsole("Content is encrypted. Initiating key request") 95 | contentKeyRequest = keyRequest 96 | handleOnlineContentKeyRequest(keyRequest: keyRequest) 97 | } 98 | 99 | /* 100 | Provides the receiver with a new content key request representing a renewal of an existing content key. 101 | Will be invoked by an AVContentKeySession as the result of a call to -renewExpiringResponseDataForContentKeyRequest:. 102 | */ 103 | func contentKeySession(_ session: AVContentKeySession, didProvideRenewingContentKeyRequest keyRequest: AVContentKeyRequest) { 104 | self.postToConsole("Renewal of an existing content key") 105 | 106 | handleOnlineContentKeyRequest(keyRequest: keyRequest) 107 | } 108 | 109 | func handleOnlineContentKeyRequest(keyRequest: AVContentKeyRequest) { 110 | if self.fpsCertificate == nil { 111 | self.postToConsole("Application Certificate missing, will request") 112 | 113 | // Request Application Certificate 114 | do { 115 | try self.requestApplicationCertificate() 116 | } catch { 117 | self.postToConsole("Failed requesting Application Certificate: \(error)") 118 | return 119 | } 120 | } 121 | 122 | /* 123 | Parse ContentId from keyRequest and capture everything after "sdk://" 124 | */ 125 | guard let contentKeyIdentifierString = keyRequest.identifier as? String, 126 | 127 | /* 128 | Capture everything after "sdk://" from #EXT-X-SESSION-KEY "URI" parameter. 129 | */ 130 | let contentIdentifier = contentKeyIdentifierString.replacingOccurrences(of: "skd://", with: "") as String?, 131 | 132 | /* 133 | Convert contentIdentifier to Unicode string (utf8) 134 | */ 135 | let contentIdentifierData = contentIdentifier.data(using: .utf8) else { 136 | postToConsole("ERROR: Failed to retrieve the contentIdentifier from the keyRequest!") 137 | return 138 | } 139 | 140 | let keyId = contentIdentifier.components(separatedBy: ":")[0] 141 | let keyIV = contentIdentifier.components(separatedBy: ":")[1] 142 | 143 | /* 144 | Console output 145 | */ 146 | let contentKeyIdAndIv = """ 147 | - Content Key ID: \(keyId) \n \ 148 | - IV(Initialization Vector): \(keyIV) \n 149 | """ 150 | 151 | postToConsole("Key request info:\n \(contentKeyIdAndIv)") 152 | 153 | /* 154 | Save Content Key Identifier String to initiate persisting content key loading process associated with the asset if needed. 155 | */ 156 | 157 | if !(asset.contentKeyIdList?.contains(contentKeyIdentifierString))! { 158 | asset.contentKeyIdList?.append(contentKeyIdentifierString) 159 | } 160 | 161 | /* 162 | When you receive an AVContentKeyRequest via -contentKeySession:didProvideContentKeyRequest: 163 | and you want the resulting key response to produce a key that can persist across multiple 164 | playback sessions, you must invoke -respondByRequestingPersistableContentKeyRequest on that 165 | AVContentKeyRequest in order to signal that you want to process an AVPersistableContentKeyRequest 166 | instead. If the underlying protocol supports persistable content keys, in response your 167 | delegate will receive an AVPersistableContentKeyRequest via -contentKeySession:didProvidePersistableContentKeyRequest:. 168 | */ 169 | if downloadRequestedByUser || persistableContentKeyExistsOnDisk(withAssetName: asset.name, withContentKeyIV: keyIV) || shouldRequestPersistableContentKey(withIdentifier: contentKeyIdentifierString) { 170 | /* 171 | Request a Persistable Key Request. 172 | */ 173 | do { 174 | self.postToConsole("User requested offline capabilities for the asset. AVPersistableContentKeyRequest object will be delivered by another delegate callback") 175 | try keyRequest.respondByRequestingPersistableContentKeyRequestAndReturnError() 176 | } catch { 177 | 178 | self.postToConsole("WARNING: User requested offline capabilities for the asset. But key loading request from an AirPlay Session requires online key") 179 | /* 180 | This case will occur when the client gets a key loading request from an AirPlay Session. 181 | You should answer the key request using an online key from your key server. 182 | */ 183 | provideOnlineKey(withKeyRequest: keyRequest, contentIdentifier: contentIdentifierData) 184 | } 185 | return 186 | } 187 | 188 | provideOnlineKey(withKeyRequest: keyRequest, contentIdentifier: contentIdentifierData) 189 | } 190 | 191 | func provideOnlineKey(withKeyRequest keyRequest: AVContentKeyRequest, contentIdentifier contentIdentifierData: Data) { 192 | 193 | postToConsole("ONLINE KEY FLOW") 194 | 195 | /* 196 | Completion handler for makeStreamingContentKeyRequestData method. 197 | 1. Sends obtained SPC to Key Server 198 | 2. Receives CKC from Key Server 199 | 3. Makes content key response object (AVContentKeyResponse) 200 | 4. Provide the content key response object to make protected content available for processing 201 | */ 202 | let getCkcAndMakeContentAvailable = { [weak self] (spcData: Data?, error: Error?) in 203 | guard let strongSelf = self else { return } 204 | 205 | if let error = error { 206 | strongSelf.postToConsole("ERROR: Failed to prepare SPC: \(error)") 207 | /* 208 | Obtaining a content key response has failed. 209 | Report error to AVFoundation. 210 | */ 211 | keyRequest.processContentKeyResponseError(error) 212 | return 213 | } 214 | 215 | guard let spcData = spcData else { return } 216 | 217 | do { 218 | strongSelf.postToConsole("Will use SPC (Server Playback Context) to request CKC (Content Key Context) from KSM (Key Security Module)") 219 | 220 | /* 221 | Send SPC to Key Server and obtain CKC. 222 | */ 223 | let ckcData = try strongSelf.requestContentKeyFromKeySecurityModule(spcData: spcData) 224 | 225 | strongSelf.postToConsole("Creating Content Key Response from CKC obtaned from Key Server") 226 | 227 | /* 228 | AVContentKeyResponse is used to represent the data returned from the key server when requesting a key for 229 | decrypting content. 230 | */ 231 | let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: ckcData) 232 | 233 | strongSelf.postToConsole("Providing Content Key Response to make protected content available for processing: \(keyResponse)") 234 | 235 | /* 236 | Provide the content key response to make protected content available for processing. 237 | */ 238 | keyRequest.processContentKeyResponse(keyResponse) 239 | } catch { 240 | strongSelf.postToConsole("Failed to make protected content available for processing: \(error)") 241 | 242 | /* 243 | Report error to AVFoundation. 244 | */ 245 | keyRequest.processContentKeyResponseError(error) 246 | } 247 | } 248 | 249 | self.postToConsole("Will prepare content key request SPC (Server Playback Context)") 250 | 251 | /* 252 | Pass Content Id unicode string together with FPS Certificate to obtain content key request data for a specific combination of application and content. 253 | */ 254 | keyRequest.makeStreamingContentKeyRequestData(forApp: self.fpsCertificate, 255 | contentIdentifier: contentIdentifierData, 256 | options: [AVContentKeyRequestProtocolVersionsKey: [1]], 257 | completionHandler: getCkcAndMakeContentAvailable) 258 | 259 | } 260 | 261 | // MARK: Offline key retrival 262 | 263 | /* 264 | Initiates content key loading process associated with an Asset for persisting on disk. 265 | */ 266 | func requestPersistableContentKeys(forAsset asset: Asset) { 267 | postToConsole("OFFLINE KEY FLOW") 268 | 269 | for contentKeyId in asset.contentKeyIdList ?? [] { 270 | postToConsole("Initiating Persistable Key Request for key identifier: \(String(describing: contentKeyId))") 271 | 272 | pendingPersistableContentKeyIdentifiers.insert(contentKeyId) 273 | 274 | contentKeySession.processContentKeyRequest(withIdentifier: contentKeyId, initializationData: nil, options: nil) 275 | } 276 | } 277 | 278 | /* 279 | Returns whether or not a content key should be persistable on disk. 280 | Parameter identifier: The asset ID associated with the content key request. 281 | - Returns: `true` if the content key request should be persistable, `false` otherwise. 282 | */ 283 | func shouldRequestPersistableContentKey(withIdentifier identifier: String) -> Bool { 284 | return pendingPersistableContentKeyIdentifiers.contains(identifier) 285 | } 286 | 287 | /* 288 | The following delegate callback gets called when the client initiates a key request or AVFoundation 289 | determines that the content is encrypted based on the playlist the client provided when it requests playback. 290 | */ 291 | func contentKeySession(_ session: AVContentKeySession, didProvide keyRequest: AVPersistableContentKeyRequest) { 292 | postToConsole("Initiating persistable key request") 293 | 294 | handlePersistableContentKeyRequest(keyRequest: keyRequest) 295 | } 296 | 297 | /* 298 | Handles responding to an `AVPersistableContentKeyRequest` by determining if a key is already available for use on disk. 299 | If no key is available on disk, a persistable key is requested from the server and securely written to disk for use in the future. 300 | In both cases, the resulting content key is used as a response for the `AVPersistableContentKeyRequest`. 301 | 302 | - Parameter keyRequest: The `AVPersistableContentKeyRequest` to respond to. 303 | */ 304 | func handlePersistableContentKeyRequest(keyRequest: AVPersistableContentKeyRequest) { 305 | /* 306 | Request Application Certificate 307 | */ 308 | if self.fpsCertificate == nil { 309 | self.postToConsole("Application Certificate missing, will request") 310 | 311 | // Request Application Certificate 312 | do { 313 | try self.requestApplicationCertificate() 314 | } catch { 315 | self.postToConsole("Failed requesting Application Certificate: \(error)") 316 | return 317 | } 318 | } 319 | 320 | /* 321 | Parse ContentId from keyRequest and capture everything after "sdk://" 322 | */ 323 | guard let contentKeyIdentifierString = keyRequest.identifier as? String, 324 | 325 | /* 326 | Capture everything after "sdk://" from #EXT-X-SESSION-KEY "URI" parameter. 327 | */ 328 | let contentIdentifier = contentKeyIdentifierString.replacingOccurrences(of: "skd://", with: "") as String?, 329 | 330 | /* 331 | Convert contentIdentifier to Unicode string (utf8) 332 | */ 333 | let contentIdentifierData = contentIdentifier.data(using: .utf8) else { 334 | postToConsole("ERROR: Failed to retrieve the contentIdentifier from the keyRequest!") 335 | return 336 | } 337 | 338 | let keyId = contentIdentifier.components(separatedBy: ":")[0] 339 | let keyIV = contentIdentifier.components(separatedBy: ":")[1] 340 | 341 | /* 342 | Console output 343 | */ 344 | let contentKeyIdAndIv = """ 345 | - Content Key ID: \(keyId) \n \ 346 | - IV(Initialization Vector): \(keyIV) \n 347 | """ 348 | postToConsole("Key request info:\n \(contentKeyIdAndIv)") 349 | 350 | /* 351 | Save Content Key Identifier String to initiate persisting content key loading process associated with the asset if needed. 352 | */ 353 | if !(asset.contentKeyIdList?.contains(contentKeyIdentifierString))! { 354 | asset.contentKeyIdList?.append(contentKeyIdentifierString) 355 | } 356 | 357 | /* 358 | Completion handler for makeStreamingContentKeyRequestData method. 359 | 1. Sends obtained SPC to Key Server 360 | 2. Receives CKC from Key Server 361 | 3. Obtains persistable content key 362 | 4. Writes persistable content key to disk 363 | 5. Makes content key response object (AVContentKeyResponse) 364 | 4. Provide the content key response object to make protected content available for processing 365 | */ 366 | let completionHandler = { [weak self] (spcData: Data?, error: Error?) in 367 | guard let strongSelf = self else { return } 368 | if let error = error { 369 | /* 370 | Report error to AVFoundation. 371 | */ 372 | keyRequest.processContentKeyResponseError(error) 373 | 374 | strongSelf.pendingPersistableContentKeyIdentifiers.remove(contentKeyIdentifierString) 375 | 376 | strongSelf.downloadRequestedByUser = false 377 | return 378 | } 379 | 380 | guard let spcData = spcData else { return } 381 | 382 | do { 383 | strongSelf.postToConsole("Will use SPC (Server Playback Context) to request CKC (Content Key Context) from KSM (Key Security Module)") 384 | /* 385 | Send SPC to Key Server and obtain CKC 386 | */ 387 | let ckcData = try strongSelf.requestContentKeyFromKeySecurityModule(spcData: spcData) 388 | 389 | strongSelf.postToConsole("Creating Content Key Response from CKC obtaned from Key Server") 390 | 391 | /* 392 | Obtains a persistable content key from Content Key Context (CKC) 393 | */ 394 | let persistentKey = try keyRequest.persistableContentKey(fromKeyVendorResponse: ckcData, options: nil) 395 | 396 | strongSelf.postToConsole("Persistable Content Key was obtained from Content Key Context (CKC)") 397 | 398 | /* 399 | Writes out a persistable content key to disk 400 | */ 401 | try strongSelf.writePersistableContentKey(contentKey: persistentKey, withAssetName: strongSelf.asset.name, withContentKeyIV: keyIV) 402 | 403 | strongSelf.postToConsole("Wrote persistable content key to disk") 404 | 405 | /* 406 | AVContentKeyResponse is used to represent the data returned from the key server when requesting a key for 407 | decrypting content. 408 | */ 409 | let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: persistentKey) 410 | 411 | /* 412 | Provide the content key response to make protected content available for processing. 413 | */ 414 | keyRequest.processContentKeyResponse(keyResponse) 415 | 416 | strongSelf.postToConsole("Providing Content Key Response to make protected content available for processing: \(keyResponse)") 417 | 418 | NotificationCenter.default.post(name: .HasAvailablePersistableContentKey, object: nil, userInfo: nil) 419 | 420 | strongSelf.pendingPersistableContentKeyIdentifiers.remove(contentKeyIdentifierString) 421 | 422 | } catch { 423 | 424 | strongSelf.postToConsole("ERROR: \(error)") 425 | 426 | /* 427 | Report error to AVFoundation. 428 | */ 429 | keyRequest.processContentKeyResponseError(error) 430 | 431 | strongSelf.pendingPersistableContentKeyIdentifiers.remove(contentKeyIdentifierString) 432 | 433 | strongSelf.downloadRequestedByUser = false 434 | } 435 | } 436 | 437 | /* 438 | Check to see if we can satisfy this key request using a saved persistent key file. 439 | */ 440 | if persistableContentKeyExistsOnDisk(withAssetName: asset.name, withContentKeyIV: keyIV) { 441 | 442 | let urlToPersistableKey = urlForPersistableContentKey(withAssetName: asset.name, withContentKeyIV: keyIV) 443 | 444 | postToConsole("Presistable key already exists on disk at location: \(urlToPersistableKey.path)") 445 | 446 | guard let contentKey = FileManager.default.contents(atPath: urlToPersistableKey.path) else { 447 | downloadRequestedByUser = false 448 | 449 | pendingPersistableContentKeyIdentifiers.remove(contentKeyIdentifierString) 450 | 451 | postToConsole("Failed to locate Presistable key from disk. Attempting to create a new one") 452 | 453 | /* 454 | Pass Content Id unicode string together with FPS Certificate to obtain content key request data for a specific combination of application and content. 455 | */ 456 | keyRequest.makeStreamingContentKeyRequestData(forApp: self.fpsCertificate, 457 | contentIdentifier: contentIdentifierData, 458 | options: [AVContentKeyRequestProtocolVersionsKey: [1]], 459 | completionHandler: completionHandler) 460 | 461 | return 462 | } 463 | 464 | /* 465 | Create an AVContentKeyResponse from the persistent key data to use for requesting a key for 466 | decrypting content. 467 | */ 468 | postToConsole("Creating Content Key Response from persistent CKC") 469 | 470 | let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: contentKey) 471 | 472 | postToConsole("Providing Content Key Response to make protected content available for processing: \(keyResponse)") 473 | 474 | /* 475 | Provide the content key response to make protected content available for processing. 476 | */ 477 | keyRequest.processContentKeyResponse(keyResponse) 478 | 479 | NotificationCenter.default.post(name: .HasAvailablePersistableContentKey, object: nil, userInfo: nil) 480 | 481 | return 482 | } 483 | 484 | keyRequest.makeStreamingContentKeyRequestData(forApp: self.fpsCertificate, 485 | contentIdentifier: contentIdentifierData, 486 | options: [AVContentKeyRequestProtocolVersionsKey: [1]], 487 | completionHandler: completionHandler) 488 | } 489 | 490 | /* 491 | Provides the receiver with an updated persistable content key for a particular key request. 492 | If the content key session provides an updated persistable content key data, the previous 493 | key data is no longer valid and cannot be used to answer future loading requests. 494 | 495 | This scenario can occur when using the FPS "dual expiry" feature which allows you to define 496 | and customize two expiry windows for FPS persistent keys. The first window is the storage 497 | expiry window which starts as soon as the persistent key is created. The other window is a 498 | playback expiry window which starts when the persistent key is used to start the playback 499 | of the media content. 500 | 501 | Here's an example: 502 | 503 | When the user rents a movie to play offline you would create a persistent key with a CKC that 504 | opts in to use this feature. This persistent key is said to expire at the end of storage expiry 505 | window which is 30 days in this example. You would store this persistent key in your apps storage 506 | and use it to answer a key request later on. When the user comes back within these 30 days and 507 | asks you to start playback of the content, you will get a key request and would use this persistent 508 | key to answer the key request. At that point, you will get sent an updated persistent key which 509 | is set to expire at the end of playback experiment which is 24 hours in this example. 510 | */ 511 | func contentKeySession(_ session: AVContentKeySession, 512 | didUpdatePersistableContentKey persistableContentKey: Data, 513 | forContentKeyIdentifier keyIdentifier: Any) { 514 | 515 | postToConsole("Updating Persistable Content Key") 516 | 517 | do { 518 | /* 519 | Parse ContentId from keyRequest and capture everything after "sdk://" 520 | */ 521 | guard let contentKeyIdentifierString = keyIdentifier as? String, 522 | 523 | /* 524 | Capture everything after "sdk://" from #EXT-X-SESSION-KEY "URI" parameter. 525 | */ 526 | let contentIdentifier = contentKeyIdentifierString.replacingOccurrences(of: "skd://", with: "") as String? 527 | 528 | else { 529 | postToConsole("ERROR: Failed to retrieve the contentIdentifier") 530 | return 531 | } 532 | 533 | deletePeristableContentKey(withAssetName: asset.name, withContentKeyId: contentIdentifier) 534 | 535 | postToConsole("Will write updated persistable content key to disk for \(asset.name)") 536 | 537 | try writePersistableContentKey(contentKey: persistableContentKey, withAssetName: asset.name, withContentKeyIV: contentIdentifier.components(separatedBy: ":")[1]) 538 | } catch { 539 | postToConsole("ERROR: Failed to write updated persistable content key to disk: \(error.localizedDescription)") 540 | } 541 | } 542 | 543 | // Writes out a persistable content key to disk. 544 | // 545 | // - Parameters: 546 | // - contentKey: The data representation of the persistable content key. 547 | // - assetName: The asset name. 548 | // - Throws: If an error occurs during the file write process. 549 | func writePersistableContentKey(contentKey: Data, withAssetName assetName: String, withContentKeyIV keyIV: String) throws { 550 | 551 | let fileURL = urlForPersistableContentKey(withAssetName: assetName, withContentKeyIV: keyIV) 552 | 553 | try contentKey.write(to: fileURL, options: Data.WritingOptions.atomicWrite) 554 | 555 | postToConsole("Wrote persistable content key to disk for \(assetName) to location: \(fileURL)") 556 | } 557 | 558 | // Returns whether or not a persistable content key exists on disk for a given asset. 559 | // 560 | // - Parameter assetName: The asset name. 561 | // - Returns: `true` if the key exists on disk, `false` otherwise. 562 | func persistableContentKeyExistsOnDisk(withAssetName assetName: String, withContentKeyIV keyIV: String) -> Bool { 563 | let contentKeyURL = urlForPersistableContentKey(withAssetName: assetName, withContentKeyIV: keyIV) 564 | 565 | return FileManager.default.fileExists(atPath: contentKeyURL.path) 566 | } 567 | 568 | // Returns the `URL` for persisting or retrieving a persistable content key. 569 | // 570 | // - Parameter assetName: The asset name. 571 | // - Returns: The fully resolved file URL. 572 | func urlForPersistableContentKey(withAssetName assetName: String, withContentKeyIV keyIV: String) -> URL { 573 | return contentKeyDirectory.appendingPathComponent("\(assetName)-\(keyIV)-Key") 574 | } 575 | 576 | // Deletes a persistable key for a given content key identifier. 577 | // 578 | // - Parameter assetName: The asset name. 579 | func deletePeristableContentKey(withAssetName assetName: String, withContentKeyId keyId: String) { 580 | 581 | /* 582 | Capture everything after "sdk://" from #EXT-X-SESSION-KEY "URI" parameter. 583 | */ 584 | guard let contentIdentifier = keyId.replacingOccurrences(of: "skd://", with: "") as String? else { 585 | postToConsole("ERROR: Failed to retrieve the contentIdentifier") 586 | return 587 | } 588 | 589 | let keyIV = contentIdentifier.components(separatedBy: ":")[1] 590 | 591 | if persistableContentKeyExistsOnDisk(withAssetName: assetName, withContentKeyIV: keyIV) { 592 | postToConsole("Deleting content key for \(assetName) - \(keyIV): Persistable content key exists on disk") 593 | } else { 594 | postToConsole("Deleting content key for \(assetName) - \(keyIV): No persistable content key exists on disk") 595 | return 596 | } 597 | 598 | let contentKeyURL = urlForPersistableContentKey(withAssetName: assetName, withContentKeyIV: keyIV) 599 | 600 | do { 601 | try FileManager.default.removeItem(at: contentKeyURL) 602 | 603 | UserDefaults.standard.removeObject(forKey: "\(assetName)-\(keyIV)-Key") 604 | 605 | postToConsole("Presistable Key for \(assetName)-\(keyIV) was deleted") 606 | } catch { 607 | print("An error occured removing the persisted content key: \(error)") 608 | } 609 | } 610 | 611 | func requestApplicationCertificate() throws { 612 | postToConsole("Requesting FPS Certificate") 613 | 614 | guard let url = URL(string: fpsCertificateUrl) else { 615 | postToConsole("ERROR: missingApplicationCertificateUrl") 616 | throw ProgramError.missingApplicationCertificateUrl 617 | } 618 | 619 | let (data, response, error) = URLSession.shared.synchronousDataTask(urlRequest: URLRequest(url: url)) 620 | 621 | if let error = error { 622 | self.postToConsole("ERROR: Error getting FPS Certificate: \(error)") 623 | throw ProgramError.applicationCertificateRequestFailed 624 | } 625 | guard response != nil else { 626 | self.postToConsole("ERROR: FPS Certificate request response empty") 627 | throw ProgramError.applicationCertificateRequestFailed 628 | } 629 | guard data != nil else { 630 | self.postToConsole("ERROR: FPS Certificate request response data is empty") 631 | throw ProgramError.applicationCertificateRequestFailed 632 | } 633 | 634 | self.fpsCertificate = data! 635 | 636 | // Retrieve useful info for logging 637 | let certificate = SecCertificateCreateWithData(nil, data! as CFData) 638 | 639 | guard certificate != nil else { 640 | self.postToConsole("ERROR: FPS Certificate data is not a valid DER-encoded") 641 | throw ProgramError.applicationCertificateRequestFailed 642 | } 643 | 644 | if let certificate = certificate { 645 | let summary = SecCertificateCopySubjectSummary(certificate) as String? 646 | 647 | if let summary = summary { 648 | self.postToConsole("FPS Certificate received, summary: \(summary)") 649 | } 650 | } 651 | } 652 | 653 | /* 654 | Deletes all the persistable content keys on disk for a specific `Asset`. 655 | - Parameter asset: The `Asset` value to remove keys for. 656 | */ 657 | func deleteAllPeristableContentKeys(forAsset asset: Asset) { 658 | for contentKeyId in asset.contentKeyIdList ?? [] { 659 | deletePeristableContentKey(withAssetName: asset.name, withContentKeyId: contentKeyId) 660 | } 661 | } 662 | 663 | func requestContentKeyFromKeySecurityModule(spcData: Data) throws -> Data { 664 | var ckcData: Data? = nil 665 | 666 | guard let url = URL(string: licensingServiceUrl) else { 667 | postToConsole("ERROR: missingLicensingServiceUrl") 668 | 669 | throw ProgramError.missingLicensingServiceUrl 670 | } 671 | 672 | /* 673 | Before sending a SPC to Key Server (KSM) we need to set provided Licensing Token to "X-AxDRM-Message" HTTP header. 674 | */ 675 | var ksmRequest = URLRequest(url: url) 676 | ksmRequest.httpMethod = "POST" 677 | ksmRequest.setValue(licensingToken, forHTTPHeaderField: "X-AxDRM-Message") 678 | ksmRequest.httpBody = spcData 679 | 680 | let (data, response, error) = URLSession.shared.synchronousDataTask(urlRequest: ksmRequest) 681 | 682 | if let error = error { 683 | postToConsole("ERROR: Error getting CKC: \(error)") 684 | throw ProgramError.noCKCReturnedByKSM 685 | } 686 | guard response != nil else { 687 | postToConsole("ERROR: CKC request response empty") 688 | throw ProgramError.noCKCReturnedByKSM 689 | 690 | } 691 | guard data != nil else { 692 | postToConsole("ERROR: CKC response data is empty") 693 | throw ProgramError.noCKCReturnedByKSM 694 | } 695 | 696 | postToConsole("SUCCESS Requesting Content Key Context (CKC) from Key Security Module (KSM)") 697 | 698 | if let httpUrlResponse = response as? HTTPURLResponse { 699 | let CKCResponseString = """ 700 | - X-AxDRM-Identity: \(httpUrlResponse.allHeaderFields["X-AxDRM-Identity"] ?? "") \n \ 701 | - X-AxDRM-Server: \(httpUrlResponse.allHeaderFields["X-AxDRM-Server"] ?? "") \n \ 702 | - X-AxDRM-Version: \(httpUrlResponse.allHeaderFields["X-AxDRM-Version"] ?? "") \n 703 | """ 704 | self.postToConsole("CKC response custom headers:\n \(CKCResponseString)") 705 | } 706 | 707 | ckcData = data 708 | 709 | guard ckcData != nil else { 710 | self.postToConsole("ERROR: No CKC returned By KSM") 711 | throw ProgramError.noCKCReturnedByKSM 712 | } 713 | 714 | return ckcData! 715 | } 716 | } 717 | -------------------------------------------------------------------------------- /DownloadedFileStructure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/DownloadedFileStructure.png -------------------------------------------------------------------------------- /InstallQR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/InstallQR.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Axinom 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /OnlineScenario.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/OnlineScenario.png -------------------------------------------------------------------------------- /PersistableScenario.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Axinom/drm-sample-player-ios/cd7369d1bd80e8185ad6b143fc2ea9ce4c03375e/PersistableScenario.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Axinom DRM Sample Player 2 | 3 | The purpose of this sample application is to provide a reference code that can help using Axinom DRM with AVFoundation framework to play FairPlay protected HTTP Live Streams (HLS) hosted on remote servers as well as giving an example of persisting FairPlay protected and non-protected HLS streams on disk for offline playback. 4 | 5 | You can use the example code provided in this sample to build your own application that integrates the Axinom DRM with AVFoundation. 6 | 7 | Another major usage of this sample raises from its capability of tracing the steps performed during the playback of protected and non-protected assets such as FairPlay content protection related activity, DRM license acquisition from Axinom DRM licensing server, as well as ```AVPlayerItem``` and ```AVPlayer``` statuses, buffer events, and Access log and Error log events associated with ```AVPlayerItem```. 8 | 9 | Sample application's Player View has a togglable Console overlay, that allows user to observe verbose logging of these steps. Console output can be cleared and copied to the device clipboard. Player buttons behind the Console overlay can be clicked only when the Console overlay is hidden. 10 | 11 | ## Using the Sample application 12 | 13 | Build and run the sample on an actual device running iOS 13.1 or later using Xcode. The APIs demonstrated in this sample do not work on the iOS Simulator. 14 | 15 | This sample provides a list of HLS Streams that you can playback by tapping on the UITableViewCell corresponding to the stream. If you wish to cancel an already running `AVAggregateAssetDownloadTask` or delete an already downloaded HLS stream from disk, you can accomplish this by tapping on the accessory button on the `UITableViewCell` corresponding to the stream you wish to manage. 16 | If you wish to download an HLS stream initiating an `AVAggregateAssetDownloadTask`, you can accomplish this by tapping the multifunction button (Save/Delete/Cancel) button on Player View Controller. Canceling and deleting downloaded stream actions are can also be performed on Player View Controller by tapping the multifunction button (Save/Delete/Cancel). 17 | 18 | When the sample creates and initializes an `AVAggregateAssetDownloadTask` for the download of an HLS stream, only the default selections for each of the media selection groups will be used (these are indicated in the HLS playlist `EXT-X-MEDIA` tags by a DEFAULT attribute of YES). 19 | 20 | ### Adding Streams to the Sample 21 | 22 | If you wish to add your own HLS streams to test with using this sample, you can do this by adding an entry into the Streams.json that is part of the Xcode Project. There are two important keys you need to provide values for: 23 | 24 | __title__: What the display name of the HLS stream should be in the sample, also used as a file name for the downloaded asset and for storage of the persistent key. 25 | 26 | __videoUrl__: The URL of the HLS stream's master playlist. 27 | 28 | __licenseServer__: Axinom DRM License Server URL. 29 | 30 | __fpsCertificateUrl__: FairPlay Streaming Certificate URL. 31 | 32 | __licenseToken__: License Token for Content Key Request. 33 | 34 | ### Application Transport Security 35 | 36 | If any of the streams you add are not hosted securely, you will need to add an Application Transport Security (ATS) exception in the Info.plist. More information on ATS and the relevant plist keys can be found in Apple documentation: 37 | 38 | Information Property List Key Reference - NSAppTransportSecurity: 39 | 40 | ## Important Notes 41 | 42 | Saving HLS streams for offline playback is only supported for VOD streams. If you try to save a live HLS stream, the system will throw an exception. 43 | 44 | ## Main Files 45 | 46 | __AssetDownloader.swift__: 47 | 48 | - `AssetDownloader` demonstrates how to manage the downloading of HLS streams. It includes APIs for starting and canceling downloads, deleting existing assets of the user's device, and monitoring the download progress and status. 49 | 50 | __ContentKeyManager.swift__: 51 | 52 | - `ContentKeyManager` class configures the instance of AVContentKeySession to use for requesting Content Keys securely for playback or offline use. 53 | 54 | __PlayerViewController.swift__: 55 | 56 | - The `PlayerViewController` uses a native AVPlayer as a base and provides a Video Player user interface together with capabilities of managing the downloading process, deleting downloaded media together with the Content Key associated with an asset. Togglable Console view allows user to see verbose logging of the steps performed during the playback of protected and non-protected assets, Fairplay content protection related activity, as well as AVPlayerItem and AVPlayer statuses, buffer events, and Access log and Error log events associated with AVPlayerItem. Console output can be cleared and copied to the device clipboard. 57 | 58 | __Asset.swift__: 59 | 60 | - `Asset` is a class that holds information about an Asset and adds its AVURLAsset as a recipient to the Playback Content Key Session in a protected playback/download use case. DownloadState extension is used to track the download states of Assets, Keys extension is used to define a number of values to use as keys in dictionary lookups. 61 | 62 |
63 | 64 | ## FairPlay Streaming Overview 65 | 66 | This sample application allows to play protected and non-protected HTTP Live Streams and preserve them on disk for offline playback. 67 | 68 | After the user requests playback of the protected HLS asset from AVFoundation m3u8 playlist will be downloaded from the Internet and parsed by AVFoundation. 69 | 70 | 71 | ### Online playback and key delivery scenario 72 | 73 | The following image demonstrates further steps performed to playback FairPlay protected media in an online scenario. 74 | 75 | ![OnlineScenario](OnlineScenario.png "OnlineScenario") 76 | 77 |
78 | 79 | ### 1. App receives key loading request from AVFoundation 80 | 81 | During parsing of the m3u8 playlist provided by the client, AVFoundation determines that the content is encrypted (m3u8 playlist contains KEY tag). 82 | As a result ```AVContentKeySessionDelegate``` will provide ```AVContentKeyRequest``` object by invoking the following delegate callback. 83 | ```swift 84 | func contentKeySession(_ session: AVContentKeySession, didProvide keyRequest: AVContentKeyRequest) { 85 | handleOnlineContentKeyRequest(keyRequest: keyRequest) 86 | } 87 | ``` 88 | 89 | Received ```AVContentKeyRequest``` object will allow performing FairPlay streaming specific operations like creating SPC - Server Playback Context (Content Key Request) and then sending it to a Key Server. For sake of simplicity we can name SPC a Content Key Request. 90 | 91 | ### 2. App requests for a Content Key Request (SPC) from AVFoundation. 92 | 93 | In ```handleOnlineContentKeyRequest``` method we check whether the Application Certificate is available, and if not, it will be requested from ```fpsCertificateUrl``` url, defined in Streams.json. 94 | 95 | ### 3. AVFoundation creates Content Key Request (SPC) 96 | As a next step, in the ```provideOnlineKey(withKeyRequest: keyRequest, contentIdentifier: contentIdentifierData)``` method we ask AVFoundation to prepare a Content Key Request (SPC) for a specific combination of application and content (Content Identifier previously parsed from m3u8 playlist). 97 | 98 | ```swift 99 | keyRequest.makeStreamingContentKeyRequestData(forApp: self.fpsCertificate, 100 | contentIdentifier: contentIdentifierData, 101 | options: [AVContentKeyRequestProtocolVersionsKey: [1]], 102 | completionHandler: completionHandler) 103 | ``` 104 | 105 | ### 4. App sends Content Key Request (SPC) to a Key Server 106 | 107 | Now in ```completionHandler``` Content Key Request (SPC) returned by ```makeStreamingContentKeyRequestData``` method will be sent to a Key Server. 108 | 109 | ```swift 110 | let ckcData = try strongSelf.requestContentKeyFromKeySecurityModule(spcData: spcData) 111 | ``` 112 | 113 | ### 5. Key Server responds back with Content Key Response (CKC) 114 | 115 | Key Server responds back with CKC - Content Key Context, for sake of simplicity we can name it Content Key Response. ```AVContentKeyResponse``` class object will be used to represent the data returned from the Key Server. 116 | ```swift 117 | let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: ckcData) 118 | ``` 119 | 120 | ### 6. App provides Content Key Response (CKC) to AVFoundation 121 | 122 | Now the app will provide the Content Key Response (CKC) to AVFoundation to make protected content available for processing. 123 | 124 | ```swift 125 | keyRequest.processContentKeyResponse(keyResponse) 126 | ``` 127 | Finally, AVFoundation can start decryption and playback. 128 | 129 |
130 | 131 | ### License renewal 132 | 133 | ```AVContentKeyRequest``` provided by ```AVContentKeySessionDelegate``` (step 1 in the online playback and key delivery scenario) is saved and used for renewing the license using 134 | ```renewExpiringResponseData(for contentKeyRequest: AVContentKeyRequest)``` function. For renewing the license in the application, "Renew" button on Player View Controller has to be clicked. 135 | 136 |
137 | 138 | ### Key delivery for offline use 139 | 140 | The following image demonstrates steps performed to deliver the persistable key that will be used to playback Fairplay protected content in an offline scenario. 141 | 142 | ![PersistableScenario](PersistableScenario.png "PersistableScenario") 143 | 144 | 145 | ### 1. The app initiates online key loading request from AVFoundation 146 | 147 | Key loading process in initiated by calling ```processContentKeyRequest``` method on ```AVContentKeySession``` instance which in current sample app is wrapped into ```requestPersistableContentKeys``` method. 148 | 149 | ```swift 150 | func requestPersistableContentKeys(forAsset asset: Asset) { 151 | contentKeySession.processContentKeyRequest(withIdentifier: asset.contentKeyId, 152 | initializationData: nil, 153 | options: nil) 154 | } 155 | ``` 156 | 157 | ### 2. AVFoundation responses with an online key loading request 158 | 159 | Once this method is called ```AVContentKeySession``` will initiate online key loading first by invoking the following delegate callback 160 | 161 | ```swift 162 | func contentKeySession(_ session: AVContentKeySession, didProvide keyRequest: AVContentKeyRequest) { 163 | handleOnlineContentKeyRequest(keyRequest: keyRequest) 164 | } 165 | ``` 166 | 167 | ### 3. The app initiates a persistable key loading request 168 | 169 | Once ```handleOnlineContentKeyRequest``` method is called, ```downloadRequestedByUser``` parameter previously set to true, will determine that initial intention was to initiate Persistable Content Key loading process. As a result Persistable Content Key will be initiated by ```respondByRequestingPersistableContentKeyRequestAndReturnError``` method: 170 | 171 | ```swift 172 | keyRequest.respondByRequestingPersistableContentKeyRequestAndReturnError() 173 | ``` 174 | 175 | ### 4. AVFoundation responses with a persistable key loading request 176 | 177 | Once this method is called ```AVContentKeySession``` will send ```AVPersistableContentKeyRequest``` object by invoking the following delegate callback: 178 | 179 | ```swift 180 | func contentKeySession(_ session: AVContentKeySession, didProvide keyRequest: AVPersistableContentKeyRequest) { 181 | handlePersistableContentKeyRequest(keyRequest: keyRequest) 182 | } 183 | ``` 184 | 185 | Received AVPersistableContentKeyRequest object will allow performing FairPlay streaming specific operations like creating SPC - Server Playback Context (Content Key Request) and sending it to a Key Server. 186 | 187 | ### 5-6. AVFoundation creates Persistable Content Key Request (SPC) 188 | 189 | In ```handlePersistableContentKeyRequest``` method we check whether the Application Certificate is available, and if not, it will be requested from ```fpsCertificateUrl``` URL, defined in Streams.json. 190 | 191 | As a next step, we ask AVFoundation to prepare a Content Key Request (SPC) for a specific combination of application and content (Content Identifier parsed from m3u8 playlist). 192 | 193 | ```swift 194 | keyRequest.makeStreamingContentKeyRequestData(forApp: self.fpsCertificate, 195 | contentIdentifier: contentIdentifierData, 196 | options: [AVContentKeyRequestProtocolVersionsKey: [1]], 197 | completionHandler: completionHandler) 198 | ``` 199 | 200 | ### 7. App sends Content Key Request (SPC) to a Key Server 201 | 202 | Now in ```completionHandler``` Content Key Request (SPC) returned by ```makeStreamingContentKeyRequestData``` method will be sent to a Key Server. 203 | 204 | ```swift 205 | let ckcData = try strongSelf.requestContentKeyFromKeySecurityModule(spcData: spcData) 206 | ``` 207 | 208 | ### 8. Key Server responses back with Content Key Response (CKC) 209 | 210 | Upon receiving the Content Key Response - CKC is passed to AVFoundation to create a persistable content key, that will be used to decrypt Fairplay protected content in offline usage. 211 | 212 | ```swift 213 | let persistentKey = try keyRequest.persistableContentKey(fromKeyVendorResponse: ckcData, options: nil) 214 | ``` 215 | 216 | Now Persistable Content Key is delivered and will be saved to the device. 217 | 218 | ```swift 219 | try strongSelf.writePersistableContentKey(contentKey: persistentKey, withAssetName: strongSelf.asset.name) 220 | ``` 221 | 222 | ### 9. App provides Content Key Response (CKC) to AVFoundation 223 | 224 | ```AVContentKeyResponse``` class object will be used to represent the data returned from the Key Server. 225 | 226 | ```swift 227 | let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: ckcData) 228 | ``` 229 | 230 | Now the app will provide the Content Key Response (CKC) to AVFoundation to make protected content available for processing. 231 | 232 | ```swift 233 | keyRequest.processContentKeyResponse(keyResponse) 234 | ``` 235 | 236 | The Persistable Content Key is now ready to be used to decrypt Fairplay protected content and AVFoundation start downloading the stream to the device. 237 | 238 | For that ```.HasAvailablePersistableContentKey``` notification will be sent. 239 | 240 | ```swift 241 | NotificationCenter.default.post(name: .HasAvailablePersistableContentKey, object: nil, userInfo: nil) 242 | ``` 243 | 244 | ### Downloading the stream to the device 245 | 246 | After dispatching the ```.HasAvailablePersistableContentKey``` notification corresponding ```handleContentKeyDelegateHasAvailablePersistableContentKey``` handler method will be called. 247 | 248 | And if an asset is now already previously saved the downloading process will be initiated by 249 | calling ```downloadStream()``` method, which is a wrapper to ```Downloader``` class method 250 | 251 | ```download(asset: Asset)``` 252 | 253 | 254 | All downloading related work is handled AVFoundation. Following classes are used for this purpose: 255 | 256 | `AVAggregateAssetDownloadTask` - the sample creates and initializes an AVAggregateAssetDownloadTask for the download of an HLS stream. Only the default media selections for each of the asset’s media selection groups are downloaded (these are indicated in the HLS playlist EXT-X-MEDIA tags by a DEFAULT attribute of YES). 257 | 258 | `AVAssetDownloadURLSession` - a URL session that supports the creation and execution of asset download tasks. 259 | 260 | Following protocols are implemented to handle the download process: 261 | 262 |
263 | 264 | ### `AVAssetDownloadDelegate` 265 | 266 | This protocol handles download-related events. 267 | 268 | Following methods are implemented to notify the app of download progress, completion events, and download location: 269 | 270 | * `urlSession(_:aggregateAssetDownloadTask:willDownloadTo:)` 271 | The method asks the delegate for the asset download location. 272 | 273 | **NOTE:** This delegate callback should only be used to save the location URL somewhere in your application. Any additional work should be done in `URLSessionTaskDelegate.urlSession(_:task:didCompleteWithError:)`. 274 | 275 | * `urlSession(_:aggregateAssetDownloadTask:didLoad:totalTimeRangesLoaded:timeRangeExpectedToLoad:for:)` 276 | Method to adopt to subscribe to progress updates of a download task 277 | 278 | * `urlSession(_:aggregateAssetDownloadTask:didCompleteFor:)` 279 | The method called when a child AVAssetDownloadTask completes for each media selection. 280 | 281 | Find out more about `AVAssetDownloadDelegate`: 282 | https://developer.apple.com/documentation/avfoundation/avassetdownloaddelegate 283 | 284 |
285 | 286 | ### `URLSessionTaskDelegate` 287 | A protocol that defines methods that URL session instance calls on their delegates to handle task-level events. 288 | 289 | The following method is implemented to notify the app that the task finished transferring data as well as to provide download related error handling: 290 | 291 | * `urlSession(_:task:didCompleteWithError:)` 292 | 293 | Find out more about `URLSessionTaskDelegate`: 294 | https://developer.apple.com/documentation/foundation/urlsessiontaskdelegate 295 | 296 | ### Downloaded content folder structure 297 | 298 | Downloaded content can be obtained and accessed as follows: 299 | 1. Open Devices and Simulators screen in Xcode (Window -> Devices and Simulators). 300 | 2. From the list of connected devices, choose the one that has Axinom DRM Sample 301 | Player installed. 302 | 3. In the opened window under installed apps section, select Axinom DRM Sample 303 | Player and then choose Download Container option accessible from the App 304 | container actions menu indicated by three period signs at the bottom). 305 | 4. To view the downloaded application container, right click on it and select 306 | Show Package Contents. While doing that, make sure that hidden files are shown 307 | in Finder (shortcut: "Command + Shift + period" to toggle hiding/showing hidden 308 | files) because some files or folders might not be visible otherwise. 309 | 310 | The following screenshot shows how the downloaded file structure looks like on 311 | an iOS device. Downloaded Fairplay keys are stored inside .keys folder. Audio 312 | and video content is located under .movpkg folder. Contents of that can also be 313 | viewed by right clicking on it and selecting the Show Package Contents option. 314 | Video fragments are saved into a subfolder which name starts with "0" and the 315 | audio fragments folder name starts with "1". The third folder named "Data" 316 | contains the HLS master playlist. Finally, boot.xml describes the .movpkg 317 | folder content. 318 | 319 | ![DownloadedFileStructure](DownloadedFileStructure.png "DownloadedFileStructure") 320 | 321 | 322 | ## Helpful Resources 323 | 324 | The following resources available on the Apple Developer website contain helpful information that you may find useful 325 | 326 | * General information regarding HLS on supported Apple devices and platforms: 327 | * [HTTP Live Streaming (HLS) - Apple Developer](https://developer.apple.com/streaming/) 328 | * [AV Foundation - Apple Developer](https://developer.apple.com/av-foundation/) 329 | * For information regarding topics specific to FairPlay Streaming as well as the latest version of the FairPlay Streaming Server SDK, please see: 330 | * [FairPlay Streaming - Apple Developer](http://developer.apple.com/streaming/fps/). 331 | * Information regarding authoring HLS content for devices and platforms: 332 | * [HLS Authoring Specification for Apple Devices](https://developer.apple.com/library/content/documentation/General/Reference/HLSAuthoringSpec/index.html#//apple_ref/doc/uid/TP40016596-CH4-SW1) 333 | * [WWDC 2016 - Session 510: Validating HTTP Live Streams](https://developer.apple.com/videos/play/wwdc2016/510/) 334 | * [WWDC 2017 - Session 515: HLS Authoring Update](https://developer.apple.com/videos/play/wwdc2017/515/) 335 | * Information regarding error handling on the server side and with AVFoundation on supported Apple devices and platforms: 336 | * [WWDC 2017 - Session 514: Error Handling Best Practices for HTTP Live Streaming](https://developer.apple.com/videos/play/wwdc2017/514/) 337 | 338 | 339 | ## Requirements 340 | 341 | ### Build 342 | 343 | Xcode 11.0 or later; iOS 13.0 SDK or later 344 | 345 | ### Runtime 346 | 347 | iOS 13.1 or later. 348 | 349 | --------------------------------------------------------------------------------