From 83bf223e12155f10c1aaf45e17630b14adf6c3f5 Mon Sep 17 00:00:00 2001 From: AJ Lauer Barinov Date: Thu, 11 Jul 2024 09:39:04 -0700 Subject: [PATCH] feature: support DRM playback (#52) * Expose DRM APIs (#36) * Support content key session operations Co-authored-by: Emily Dixon Co-authored-by: AJ Lauer Barinov --- .../project.pbxproj | 4 + .../MuxPlayerSwiftExample/AppDelegate.swift | 9 +- .../Base.lproj/Main.storyboard | 43 ++ .../DRMExampleViewController.swift | 113 +++ .../MuxPlayerSwiftExample/Info.plist | 4 + .../ProcessInfo+EnvironmentVariables.swift | 73 ++ README.md | 4 + .../FairPlay/ContentKeySessionDelegate.swift | 338 +++++++++ .../FairPlay/FairPlaySessionManager.swift | 365 ++++++++++ .../GlobalLifecycle/PlayerSDK.swift | 104 ++- .../InternalExtensions/AVPlayerItem+Mux.swift | 206 +----- .../URLComponents+Mux.swift | 164 +++++ .../AVPlayerViewController+Mux.swift | 2 +- .../PublicAPI/Options/PlaybackOptions.swift | 42 +- .../PublicAPI/Version/SemanticVersion.swift | 2 +- .../ContentKeySessionDelegateTests.swift | 188 +++++ .../FairPlaySessionManagerTests.swift | 641 ++++++++++++++++++ Tests/MuxPlayerSwift/Helpers/FakeError.swift | 12 + .../Helpers/MockKeyRequest.swift | 82 +++ .../Helpers/MockURLProtocol.swift | 49 ++ .../Helpers/TestContentKeySession.swift | 37 + ...PlayStreamingSessionCredentialClient.swift | 61 ++ .../TestFairPlayStreamingSessionManager.swift | 50 ++ .../Helpers/TestPlaybackOptionsRegistry.swift | 28 + 24 files changed, 2420 insertions(+), 201 deletions(-) create mode 100644 Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/DRMExampleViewController.swift create mode 100644 Sources/MuxPlayerSwift/FairPlay/ContentKeySessionDelegate.swift create mode 100644 Sources/MuxPlayerSwift/FairPlay/FairPlaySessionManager.swift create mode 100644 Sources/MuxPlayerSwift/InternalExtensions/URLComponents+Mux.swift create mode 100644 Tests/MuxPlayerSwift/FairPlay/ContentKeySessionDelegateTests.swift create mode 100644 Tests/MuxPlayerSwift/FairPlay/FairPlaySessionManagerTests.swift create mode 100644 Tests/MuxPlayerSwift/Helpers/FakeError.swift create mode 100644 Tests/MuxPlayerSwift/Helpers/MockKeyRequest.swift create mode 100644 Tests/MuxPlayerSwift/Helpers/MockURLProtocol.swift create mode 100644 Tests/MuxPlayerSwift/Helpers/TestContentKeySession.swift create mode 100644 Tests/MuxPlayerSwift/Helpers/TestFairPlayStreamingSessionCredentialClient.swift create mode 100644 Tests/MuxPlayerSwift/Helpers/TestFairPlayStreamingSessionManager.swift create mode 100644 Tests/MuxPlayerSwift/Helpers/TestPlaybackOptionsRegistry.swift diff --git a/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample.xcodeproj/project.pbxproj b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample.xcodeproj/project.pbxproj index b4c37939..e99bd8c4 100644 --- a/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample.xcodeproj/project.pbxproj +++ b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 19DD16AF2BEC010400F4DF4F /* SinglePlayerExampleController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19DD16AE2BEC010400F4DF4F /* SinglePlayerExampleController.swift */; }; 19DD16B12BEC028C00F4DF4F /* SmartCacheExampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19DD16B02BEC028C00F4DF4F /* SmartCacheExampleViewController.swift */; }; 19DD16B32BEC048300F4DF4F /* SinglePlayerLayerExampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19DD16B22BEC048300F4DF4F /* SinglePlayerLayerExampleViewController.swift */; }; + 19F6A1CE2C2F23EC00EE408A /* DRMExampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19F6A1CD2C2F23EC00EE408A /* DRMExampleViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -56,6 +57,7 @@ 19DD16AE2BEC010400F4DF4F /* SinglePlayerExampleController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SinglePlayerExampleController.swift; sourceTree = ""; }; 19DD16B02BEC028C00F4DF4F /* SmartCacheExampleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartCacheExampleViewController.swift; sourceTree = ""; }; 19DD16B22BEC048300F4DF4F /* SinglePlayerLayerExampleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SinglePlayerLayerExampleViewController.swift; sourceTree = ""; }; + 19F6A1CD2C2F23EC00EE408A /* DRMExampleViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DRMExampleViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -112,6 +114,7 @@ 19DD16AE2BEC010400F4DF4F /* SinglePlayerExampleController.swift */, 19DD16B02BEC028C00F4DF4F /* SmartCacheExampleViewController.swift */, 19DD16B22BEC048300F4DF4F /* SinglePlayerLayerExampleViewController.swift */, + 19F6A1CD2C2F23EC00EE408A /* DRMExampleViewController.swift */, 193228C02ACF6AC700966FE1 /* Main.storyboard */, 193228C32ACF6AC800966FE1 /* Assets.xcassets */, 193228C52ACF6AC800966FE1 /* LaunchScreen.storyboard */, @@ -275,6 +278,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 19F6A1CE2C2F23EC00EE408A /* DRMExampleViewController.swift in Sources */, 19DD16B32BEC048300F4DF4F /* SinglePlayerLayerExampleViewController.swift in Sources */, 193228BB2ACF6AC700966FE1 /* AppDelegate.swift in Sources */, 19DD16B12BEC028C00F4DF4F /* SmartCacheExampleViewController.swift in Sources */, diff --git a/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/AppDelegate.swift b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/AppDelegate.swift index db0cbcb4..eb70da39 100644 --- a/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/AppDelegate.swift +++ b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/AppDelegate.swift @@ -3,12 +3,19 @@ // MuxPlayerSwiftExample // +import AVFoundation import UIKit @main class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(AVAudioSession.Category.playback) + } catch { + print("Setting category to AVAudioSessionCategoryPlayback failed.") + } + return true } diff --git a/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/Base.lproj/Main.storyboard b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/Base.lproj/Main.storyboard index 1b849faa..d1f0c7f3 100644 --- a/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/Base.lproj/Main.storyboard +++ b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/Base.lproj/Main.storyboard @@ -101,6 +101,33 @@ + + + + + + + + + + + + + + + @@ -163,6 +190,22 @@ + + + + + + + + + + + + + + + + diff --git a/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/DRMExampleViewController.swift b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/DRMExampleViewController.swift new file mode 100644 index 00000000..ff97d1e4 --- /dev/null +++ b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/DRMExampleViewController.swift @@ -0,0 +1,113 @@ +// +// DRMExampleViewController.swift +// MuxPlayerSwiftExample +// + +import AVKit +import UIKit + +import MuxPlayerSwift + +class DRMExampleViewController: UIViewController { + + // MARK: Player View Controller + + lazy var playerViewController = AVPlayerViewController( + playbackID: playbackID, + playbackOptions: PlaybackOptions( + playbackToken: playbackToken, + drmToken: drmToken, + customDomain: customDomain + ) + ) + + // MARK: Mux Data Monitoring Parameters + + var playerName: String = "MuxPlayerSwift-DRMExample" + + var environmentKey: String? { + ProcessInfo.processInfo.environmentKey + } + + // MARK: Mux Video Playback Parameters + + var playbackID: String { + ProcessInfo.processInfo.playbackID ?? "qxb01i6T202018GFS02vp9RIe01icTcDCjVzQpmaB00CUisJ4" + } + + // TODO: Display error alert if ProcessInfo returns nil + var playbackToken: String { + ProcessInfo.processInfo.playbackToken ?? "" + } + + // TODO: Display error alert if ProcessInfo returns nil + var drmToken: String { + ProcessInfo.processInfo.drmToken ?? "" + } + + // TODO: Display error alert if ProcessInfo returns nil + var customDomain: String? { + ProcessInfo.processInfo.customDomain ?? nil + } + + // MARK: Status Bar Appearance + + override var childForStatusBarStyle: UIViewController? { + playerViewController + } + + // MARK: View Controller Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + + playerViewController.delegate = self + playerViewController.allowsPictureInPicturePlayback = true + playerViewController.canStartPictureInPictureAutomaticallyFromInline = true + + displayPlayerViewController() + } + + // MARK: Player Lifecycle + + func displayPlayerViewController() { + playerViewController.willMove(toParent: self) + addChild(playerViewController) + view.addSubview(playerViewController.view) + playerViewController.didMove(toParent: self) + playerViewController + .view + .translatesAutoresizingMaskIntoConstraints = false + view.addConstraints([ + playerViewController.view.leadingAnchor.constraint( + equalTo: view.leadingAnchor + ), + playerViewController.view.trailingAnchor.constraint( + equalTo: view.trailingAnchor + ), + playerViewController.view.layoutMarginsGuide.topAnchor.constraint( + equalTo: view.topAnchor + ), + playerViewController.view.layoutMarginsGuide.bottomAnchor + .constraint(equalTo: view.bottomAnchor), + ]) + } + + func hidePlayerViewController() { + playerViewController.willMove(toParent: nil) + playerViewController.view.removeFromSuperview() + playerViewController.removeFromParent() + } + +} + +extension DRMExampleViewController: AVPlayerViewControllerDelegate{ + func playerViewController( + _ playerViewController: AVPlayerViewController, + restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void + ) { + completionHandler(true) + } +} diff --git a/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/Info.plist b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/Info.plist index dd3c9afd..8e6b5a88 100644 --- a/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/Info.plist +++ b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/Info.plist @@ -21,5 +21,9 @@ + UIBackgroundModes + + audio + diff --git a/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/ProcessInfo+EnvironmentVariables.swift b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/ProcessInfo+EnvironmentVariables.swift index 3c722e86..ac9d312f 100644 --- a/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/ProcessInfo+EnvironmentVariables.swift +++ b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample/ProcessInfo+EnvironmentVariables.swift @@ -5,7 +5,11 @@ import Foundation +// Configure these as environment variables in +// application scheme extension ProcessInfo { + +// MARK: Mux Data Environment Key var environmentKey: String? { guard let value = environment["ENV_KEY"], !value.isEmpty else { @@ -15,6 +19,8 @@ extension ProcessInfo { return value } +// MARK: Mux Video Playback Constants + var playbackID: String? { guard let value = environment["PLAYBACK_ID"], !value.isEmpty else { @@ -23,4 +29,71 @@ extension ProcessInfo { return value } + + var playbackToken: String? { + guard let value = environment["PLAYBACK_TOKEN"], + !value.isEmpty else { + return nil + } + + return value + } + + var drmToken: String? { + guard let value = environment["DRM_TOKEN"], + !value.isEmpty else { + return nil + } + + return value + } + + var secondaryPlaybackID: String? { + guard let value = environment["SECONDARY_PLAYBACK_ID"], + !value.isEmpty else { + return nil + } + + return value + } + + var secondaryPlaybackToken: String? { + guard let value = environment["SECONDARY_PLAYBACK_TOKEN"], + !value.isEmpty else { + return nil + } + + return value + } + + var secondaryDRMToken: String? { + guard let value = environment["SECONDARY_DRM_TOKEN"], + !value.isEmpty else { + return nil + } + + return value + } + +// MARK: Mux Video Custom Playback Domain + + + /// Once Mux has provisioned your Custom Domain, set the + /// `CUSTOM_DOMAIN` environment variable and Mux Player + /// Swift will pass that on to `AVPlayer` to use when + /// streaming video. + /// + /// Mux Player Swift automatically prepends the `stream` + /// subdomain for you. This means that if you set + /// `media.example.com` as `CUSTOM_DOMAIN` then `AVPlayer` + /// will use `stream.media.example.com` when requesting + /// media. [See here for more details](https://www.mux.com/blog/introducing-custom-domains). + var customDomain: String? { + guard let value = environment["CUSTOM_DOMAIN"], + !value.isEmpty else { + return nil + } + + return value + } } diff --git a/README.md b/README.md index 7620b4d4..d9137700 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ A collection of helpful utilities for using AVKit and AVFoundation to stream vid We'd love to hear your feedback, shoot us a note at avplayer@mux.com with any feature requests, API feedback, or to tell us about what you'd like to build. +#### Mux Video DRM Beta + +This SDK supports Mux Video's DRM feature, which is currently in closed beta. If you are interested in using our DRM features, please sign up on our [beta page](https://www.mux.com/beta/drm) + ## Installation ### Installing in Xcode using Swift Package Manager diff --git a/Sources/MuxPlayerSwift/FairPlay/ContentKeySessionDelegate.swift b/Sources/MuxPlayerSwift/FairPlay/ContentKeySessionDelegate.swift new file mode 100644 index 00000000..b08692e1 --- /dev/null +++ b/Sources/MuxPlayerSwift/FairPlay/ContentKeySessionDelegate.swift @@ -0,0 +1,338 @@ +// +// ContentKeySessionDelegate.swift +// +// +// Created by Emily Dixon on 4/19/24. +// + +import AVFoundation +import Foundation +import os + +class ContentKeySessionDelegate : NSObject, AVContentKeySessionDelegate { + + weak var sessionManager: SessionManager? + + var logger: Logger + + init( + sessionManager: SessionManager + ) { + self.sessionManager = sessionManager + self.logger = sessionManager.logger + } + + // MARK: AVContentKeySessionDelegate implementation + + func contentKeySession( + _ session: AVContentKeySession, + didProvide keyRequest: AVContentKeyRequest + ) { + handleContentKeyRequest( + request: DefaultKeyRequest(wrapping: keyRequest) + ) + } + + func contentKeySession( + _ session: AVContentKeySession, + didProvideRenewingContentKeyRequest keyRequest: AVContentKeyRequest + ) { + handleContentKeyRequest(request: DefaultKeyRequest(wrapping: keyRequest)) + } + + func contentKeySession( + _ session: AVContentKeySession, + contentKeyRequestDidSucceed keyRequest: AVContentKeyRequest + ) { + logger.debug( + "CK Request Succeeded" + ) + } + + func contentKeySession( + _ session: AVContentKeySession, contentKeyRequest + keyRequest: AVContentKeyRequest, didFailWithError + err: any Error + ) { + logger.debug( + "CK Request Failed Error: \(err.localizedDescription)" + ) + } + + func contentKeySession( + _ session: AVContentKeySession, + shouldRetry keyRequest: AVContentKeyRequest, + reason retryReason: AVContentKeyRequest.RetryReason + ) -> Bool { + logger.debug( + "Retrying with reason: \(retryReason.rawValue)" + ) + + switch retryReason { + /* + Indicates that the content key request should be retried because the key response was not set soon enough either + due the initial request/response was taking too long, or a lease was expiring in the meantime. + */ + case .timedOut: + return true + + /* + Indicates that the content key request should be retried because a key response with expired lease was set on the + previous content key request. + */ + case .receivedResponseWithExpiredLease: + return true + + /* + Indicates that the content key request should be retried because an obsolete key response was set on the previous + content key request. + */ + case .receivedObsoleteContentKey: + return true + + default: + return false + } + } + + // MARK: Logic + + func parsePlaybackId(fromSkdLocation uri: URL) -> String? { + // pull the playbackID out of the uri to the key + guard let urlComponents = URLComponents( + url: uri, + resolvingAgainstBaseURL: false + ) else { + // not likely + logger.debug("\(#function) Error: cannot parse key URL [\(uri)]") + return nil + } + + return urlComponents.findQueryValue( + key: "playbackId" + ) + } + + func handleContentKeyRequest(request: any KeyRequest) { + logger.debug( + "Called \(#function)" + ) + + guard let sessionManager = self.sessionManager else { + // TODO: Should this also invoke `processContentKeyResponseError`? + logger.debug("Missing session manager") + return + } + + // for hls, "the identifier must be an NSURL that matches a key URI in the Media Playlist." from the docs + guard let requestIdentifierString = request.identifier as? String, + let mediaPlaylistKeyURL = URL(string: requestIdentifierString), + let utfEncodedRequestIdentifierString = requestIdentifierString.data(using: .utf8) + else { + // TODO: Should this also invoke `processContentKeyResponseError`? + logger.debug( + "CK request identifier not a valid key url." + ) + return + } + + guard let playbackID = parsePlaybackId( + fromSkdLocation: mediaPlaylistKeyURL + ) else { + request.processContentKeyResponseError( + FairPlaySessionError.unexpected( + message: "playbackID not present in key uri" + ) + ) + logger.debug("\(#function) Error: key url SDK location missing playbackId [\(mediaPlaylistKeyURL.absoluteString)]") + return + } + + guard let playbackOptions = sessionManager.findRegisteredPlaybackOptions( + for: playbackID + ), case .drm(let drmOptions) = playbackOptions.playbackPolicy else { + logger.debug( + "Unregistered DRM token. Make sure this is done right after AVPlayerItem is initialized." + ) + request.processContentKeyResponseError( + FairPlaySessionError.unexpected( + message: "Token was not registered, only happens during SDK errors" + ) + ) + return + } + + let rootDomain = playbackOptions.rootDomain() + + // get app cert + var applicationCertificate: Data? + var appCertError: (any Error)? + // the drmtoday example does this by joining a dispatch group, but is this best? + let group = DispatchGroup() + group.enter() + sessionManager.requestCertificate( + fromDomain: rootDomain, + playbackID: playbackID, + drmToken: drmOptions.drmToken, + completion: { result in + do { + applicationCertificate = try result.get() + } catch { + appCertError = error + } + group.leave() + } + ) + group.wait() + guard let applicationCertificate = applicationCertificate else { + request.processContentKeyResponseError( + FairPlaySessionError.because( + cause: appCertError! + ) + ) + return + } + + // exchange app cert for SPC using KeyRequest to give to CDM + request.makeStreamingContentKeyRequestData( + forApp: applicationCertificate, + contentIdentifier: utfEncodedRequestIdentifierString, + options: [AVContentKeyRequestProtocolVersionsKey: [1]] + ) { [weak self] spcData, error in + guard let self = self else { + PlayerSDK.shared.diagnosticsLogger.debug( + "Content key request completed: missing session delegate" + ) + return + } + + guard let spcData = spcData else { + request.processContentKeyResponseError( + error ?? FairPlaySessionError.unexpected(message: "no SPC") + ) + return + } + + // exchange SPC for CKC + handleSpcObtainedFromCDM( + spcData: spcData, + playbackID: playbackID, + drmToken: drmOptions.drmToken, + rootDomain: rootDomain, + request: request + ) + } + } + + func handleSpcObtainedFromCDM( + spcData: Data, + playbackID: String, + drmToken: String, + rootDomain: String, // without any "license." or "stream." prepended, eg mux.com, custom.1234.co.uk + request: any KeyRequest + ) { + guard let sessionManager = self.sessionManager else { + logger.debug("Missing Session Manager") + return + } + + // todo - DRM Today example does this by joining a DispatchGroup. Is this really preferable?? + var ckcData: Data? = nil + let group = DispatchGroup() + group.enter() + sessionManager.requestLicense( + spcData: spcData, + playbackID: playbackID, + drmToken: drmToken, + rootDomain: rootDomain, + offline: false + ) { result in + if let data = try? result.get() { + ckcData = data + } + group.leave() + } + group.wait() + + guard let ckcData = ckcData else { + request.processContentKeyResponseError( + FairPlaySessionError.unexpected( + message: "No CKC Data returned from CDM" + ) + ) + return + } + + logger.debug("Submitting CKC to system") + // Send CKC to CDM/wherever else so we can finally play our content + let keyResponse = request.makeContentKeyResponse( + data: ckcData + ) + request.processContentKeyResponse( + keyResponse + ) + logger.debug("Protected content now available for processing") + // Done! no further interaction is required from us to play. + } +} + +// Wraps a generic request for a key and delegates calls to it +// this protocol's methods are intended to match AVContentKeyRequest +protocol KeyRequest { + + associatedtype InnerRequest + + var identifier: Any? { get } + + func makeContentKeyResponse(data: Data) -> AVContentKeyResponse + + func processContentKeyResponse(_ response: AVContentKeyResponse) + func processContentKeyResponseError(_ error: any Error) + func makeStreamingContentKeyRequestData(forApp appIdentifier: Data, + contentIdentifier: Data?, + options: [String : Any]?, + completionHandler handler: @escaping (Data?, (any Error)?) -> Void) +} + +// Wraps a real AVContentKeyRequest and straightforwardly delegates to it +struct DefaultKeyRequest : KeyRequest { + typealias InnerRequest = AVContentKeyRequest + + var identifier: Any? { + get { + return self.request.identifier + } + } + + func makeContentKeyResponse(data: Data) -> AVContentKeyResponse { + return AVContentKeyResponse(fairPlayStreamingKeyResponseData: data) + } + + func processContentKeyResponse(_ response: AVContentKeyResponse) { + self.request.processContentKeyResponse(response) + } + + func processContentKeyResponseError(_ error: any Error) { + self.request.processContentKeyResponseError(error) + } + + func makeStreamingContentKeyRequestData( + forApp appIdentifier: Data, + contentIdentifier: Data?, + options: [String : Any]? = nil, + completionHandler handler: @escaping (Data?, (any Error)?) -> Void + ) { + self.request.makeStreamingContentKeyRequestData( + forApp: appIdentifier, + contentIdentifier: contentIdentifier, + options: options, + completionHandler: handler + ) + } + + let request: InnerRequest + + init(wrapping request: InnerRequest) { + self.request = request + } +} diff --git a/Sources/MuxPlayerSwift/FairPlay/FairPlaySessionManager.swift b/Sources/MuxPlayerSwift/FairPlay/FairPlaySessionManager.swift new file mode 100644 index 00000000..ee7c1d7c --- /dev/null +++ b/Sources/MuxPlayerSwift/FairPlay/FairPlaySessionManager.swift @@ -0,0 +1,365 @@ +// +// FairplaySessionManager.swift +// +// +// Created by Emily Dixon on 4/19/24. +// + +import AVFoundation +import Foundation +import os + +// MARK: - FairPlayStreamingSessionManager + +// Use AnyObject to restrict conformances only to reference +// types because the SDKs AVContentKeySessionDelegate holds +// a weak reference to the SDKs witness of this. +protocol FairPlayStreamingSessionCredentialClient: AnyObject { + // MARK: Requesting licenses and certs + + // Requests the App Certificate for a playback id + func requestCertificate( + fromDomain rootDomain: String, + playbackID: String, + drmToken: String, + completion requestCompletion: @escaping (Result) -> Void + ) + // Requests a license to play based on the given SPC data + // - parameter offline - Not currently used, may not ever be used in short-term, maybe delete? + func requestLicense( + spcData: Data, + playbackID: String, + drmToken: String, + rootDomain: String, + offline _: Bool, + completion requestCompletion: @escaping (Result) -> Void + ) + + var logger: Logger { get set } +} + +// MARK: - PlaybackOptionsRegistry + +protocol PlaybackOptionsRegistry: AnyObject { + /// Registers a ``PlaybackOptions`` for DRM playback, associated with the given playbackID + func registerPlaybackOptions(_ opts: PlaybackOptions, for playbackID: String) + /// Gets a DRM token previously registered via ``registerPlaybackOptions`` + func findRegisteredPlaybackOptions(for playbackID: String) -> PlaybackOptions? + /// Unregisters a ``PlaybackOptions`` for DRM playback, given the assiciated playback ID + func unregisterPlaybackOptions(for playbackID: String) +} + +// MARK: - ContentKeyRecipientRegistry + +// Intended for registering drm-protected AVURLAssets +protocol ContentKeyRecipientRegistry { + /// Adds a ``AVContentKeyRecipient`` (probably an ``AVURLAsset``) that must be played + /// with DRM protection. This call is necessary for DRM playback to succeed + func addContentKeyRecipient(_ recipient: AVContentKeyRecipient) + /// Removes a ``AVContentKeyRecipient`` previously added by ``addContentKeyRecipient`` + func removeContentKeyRecipient(_ recipient: AVContentKeyRecipient) +} + +// MARK: - FairPlayStreamingSessionManager + +typealias FairPlayStreamingSessionManager = FairPlayStreamingSessionCredentialClient & PlaybackOptionsRegistry & ContentKeyRecipientRegistry + +// MARK: - Content Key Provider + +// Define protocol for calls made to AVContentKeySession +protocol ContentKeyProvider { + func setDelegate( + _ delegate: (any AVContentKeySessionDelegate)?, + queue delegateQueue: dispatch_queue_t? + ) + + func addContentKeyRecipient(_ recipient: any AVContentKeyRecipient) + + func removeContentKeyRecipient(_ recipient: any AVContentKeyRecipient) +} + +// AVContentKeySession already has built-in definitions for +// these methods so this declaration can be empty +extension AVContentKeySession: ContentKeyProvider { } + +// MARK: - DefaultFairPlayStreamingSessionManager + +class DefaultFairPlayStreamingSessionManager< + ContentKeySession: ContentKeyProvider +>: FairPlayStreamingSessionManager { + + var playbackOptionsByPlaybackID: [String: PlaybackOptions] = [:] + let contentKeySession: ContentKeySession + + #if DEBUG + var logger: Logger = Logger( + OSLog( + subsystem: "com.mux.player", + category: "CK" + ) + ) + #else + var logger: Logger = Logger( + OSLog.disabled + ) + #endif + + var sessionDelegate: AVContentKeySessionDelegate? { + didSet { + contentKeySession.setDelegate( + sessionDelegate, + queue: DispatchQueue( + label: "com.mux.player.fairplay" + ) + ) + } + } + + private let urlSession: URLSession + + func addContentKeyRecipient(_ recipient: AVContentKeyRecipient) { + contentKeySession.addContentKeyRecipient(recipient) + } + + func removeContentKeyRecipient(_ recipient: AVContentKeyRecipient) { + contentKeySession.removeContentKeyRecipient(recipient) + } + + // MARK: Requesting licenses and certs + + /// Requests the App Certificate for a playback id + func requestCertificate( + fromDomain rootDomain: String, + playbackID: String, + drmToken: String, + completion requestCompletion: @escaping (Result) -> Void + ) { + guard let url = URLComponents( + playbackID: playbackID, + drmToken: drmToken, + applicationCertificateHostSuffix: rootDomain + ).url else { + logger.debug( + "Invalid FairPlay certificate domain \(rootDomain, privacy: .auto(mask: .hash))" + ) + requestCompletion( + Result.failure( + FairPlaySessionError.unexpected( + message: "Invalid certificate domain" + ) + ) + ) + return + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + + logger.debug( + "Requesting application certificate from \(url, privacy: .auto(mask: .hash))" + ) + + let dataTask = urlSession.dataTask(with: request) { [requestCompletion] data, response, error in + self.logger.debug( + "Application certificate request completed" + ) + + var responseCode: Int? = nil + if let httpResponse = response as? HTTPURLResponse { + responseCode = httpResponse.statusCode + self.logger.debug( + "Application certificate response code: \(httpResponse.statusCode)" + ) + self.logger.debug( + "Application certificate response headers: \(httpResponse.allHeaderFields, privacy: .auto(mask: .hash))" + ) + if let data, let utfData = String( + data: data, + encoding: .utf8 + ) { + self.logger.debug( + "Application certificate error: \(utfData)" + ) + } + + } + // error case: I/O failed + if let error = error { + self.logger.debug( + "Applicate certificate request failed with error: \(error.localizedDescription)" + ) + requestCompletion(Result.failure( + FairPlaySessionError.because(cause: error) + )) + return + } + // error case: I/O finished with non-successful response + guard responseCode == 200 else { + self.logger.debug( + "Applicate certificate request failed with response code: \(String(describing: responseCode))" + ) + requestCompletion( + Result.failure( + FairPlaySessionError.httpFailed( + responseStatusCode: responseCode ?? 0 + ) + ) + ) + return + } + // this edge case (200 with invalid data) is possible from our DRM vendor + guard let data = data, + data.count > 0 else { + self.logger.debug( + "Applicate certificate request completed with missing data and response code \(responseCode.debugDescription)" + ) + requestCompletion( + Result.failure( + FairPlaySessionError.unexpected( + message: "No cert data with 200 OK respone" + ) + ) + ) + return + } + + self.logger.debug("Application certificate response data:\(data.base64EncodedString(), privacy: .auto(mask: .hash))") + + requestCompletion(Result.success(data)) + } + + dataTask.resume() + } + + /// Requests a license to play based on the given SPC data + /// - parameter offline - Not currently used, may not ever be used in short-term, maybe delete? + func requestLicense( + spcData: Data, + playbackID: String, + drmToken: String, + rootDomain: String, + offline _: Bool, + completion requestCompletion: @escaping (Result) -> Void + ) { + guard let url = URLComponents( + playbackID: playbackID, + drmToken: drmToken, + licenseHostSuffix: rootDomain + ).url else { + requestCompletion( + Result.failure( + FairPlaySessionError.unexpected( + message: "Invalid FairPlay license domain" + ) + ) + ) + return + } + + var request = URLRequest(url: url) + + // POST body is the SPC bytes + request.httpMethod = "POST" + request.httpBody = spcData + + // QUERY PARAMS + request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") + request.setValue(String(format: "%lu", request.httpBody?.count ?? 0), forHTTPHeaderField: "Content-Length") + logger.debug("Sending License/CKC Request to: \(request.url?.absoluteString ?? "nil")") + logger.debug("\t with header fields: \(String(describing: request.allHTTPHeaderFields))") + + let task = urlSession.dataTask(with: request) { [requestCompletion] data, response, error in + // error case: I/O failed + if let error = error { + self.logger.debug( + "URL Session Task Failed: \(error.localizedDescription)" + ) + requestCompletion(Result.failure( + FairPlaySessionError.because(cause: error) + )) + return + } + + var responseCode: Int? = nil + if let httpResponse = response as? HTTPURLResponse { + responseCode = httpResponse.statusCode + self.logger.debug( + "License response code: \(httpResponse.statusCode)" + ) + self.logger.debug( + "License response headers: \(httpResponse.allHeaderFields, privacy: .auto(mask: .hash))" + ) + } + // error case: I/O finished with non-successful response + guard responseCode == 200 else { + self.logger.debug( + "CKC request failed: \(String(describing: responseCode))" + ) + requestCompletion(Result.failure( + FairPlaySessionError.httpFailed( + responseStatusCode: responseCode ?? 0 + ) + )) + return + } + // strange edge case: 200 with no response body + // this happened because of a client-side encoding difference causing an error + // with our drm vendor and probably shouldn't be reachable, but lets not crash + guard let data = data, + data.count > 0 + else { + self.logger.debug("No CKC data despite server returning success") + requestCompletion(Result.failure( + FairPlaySessionError.unexpected(message: "No license data with 200 response") + )) + return + } + + let ckcData = data + requestCompletion(Result.success(ckcData)) + } + task.resume() + } + + // MARK: registering assets + + /// Registers a ``PlaybackOptions`` for DRM playback, associated with the given playbackID + func registerPlaybackOptions( + _ options: PlaybackOptions, + for playbackID: String + ) { + logger.debug("Registering playbackID \(playbackID)") + playbackOptionsByPlaybackID[playbackID] = options + } + + /// Gets a DRM token previously registered via ``registerPlaybackOptions`` + func findRegisteredPlaybackOptions( + for playbackID: String + ) -> PlaybackOptions? { + logger.debug("Finding playbackID \(playbackID)") + return playbackOptionsByPlaybackID[playbackID] + } + + /// Unregisters a ``PlaybackOptions`` for DRM playback, given the assiciated playback ID + func unregisterPlaybackOptions(for playbackID: String) { + logger.debug("UN-Registering playbackID \(playbackID)") + playbackOptionsByPlaybackID.removeValue(forKey: playbackID) + } + + // MARK: initializers + + init( + contentKeySession: ContentKeySession, + urlSession: URLSession + ) { + self.contentKeySession = contentKeySession + self.urlSession = urlSession + } +} + +// MARK: - FairPlaySessionError + +enum FairPlaySessionError : Error { + case because(cause: any Error) + case httpFailed(responseStatusCode: Int) + case unexpected(message: String) +} diff --git a/Sources/MuxPlayerSwift/GlobalLifecycle/PlayerSDK.swift b/Sources/MuxPlayerSwift/GlobalLifecycle/PlayerSDK.swift index 9bfafb31..5d8ee229 100644 --- a/Sources/MuxPlayerSwift/GlobalLifecycle/PlayerSDK.swift +++ b/Sources/MuxPlayerSwift/GlobalLifecycle/PlayerSDK.swift @@ -17,16 +17,43 @@ class PlayerSDK { let monitor: Monitor - let diagnosticsLogger: Logger + var diagnosticsLogger: Logger - let abrLogger: Logger + var abrLogger: Logger let reverseProxyServer: ReverseProxyServer let keyValueObservation = KeyValueObservation() - init() { + let fairPlaySessionManager: FairPlayStreamingSessionManager + + convenience init() { + #if targetEnvironment(simulator) + self.init( + fairPlayStreamingSessionManager: DefaultFairPlayStreamingSessionManager( + contentKeySession: AVContentKeySession(keySystem: .clearKey), + urlSession: .shared + ) + ) + #else + let sessionManager = DefaultFairPlayStreamingSessionManager( + contentKeySession: AVContentKeySession(keySystem: .fairPlayStreaming), + urlSession: .shared + ) + sessionManager.sessionDelegate = ContentKeySessionDelegate( + sessionManager: sessionManager + ) + self.init( + fairPlayStreamingSessionManager: sessionManager + ) + #endif + } + + init( + fairPlayStreamingSessionManager: FairPlayStreamingSessionManager + ) { self.monitor = Monitor() + self.fairPlaySessionManager = fairPlayStreamingSessionManager #if DEBUG self.abrLogger = Logger( @@ -53,6 +80,58 @@ class PlayerSDK { self.reverseProxyServer = ReverseProxyServer() } + func enableLogging() { + self.abrLogger = Logger( + OSLog( + subsystem: "com.mux.player", + category: "ABR" + ) + ) + self.diagnosticsLogger = Logger( + OSLog( + subsystem: "com.mux.player", + category: "Diagnostics" + ) + ) + self.fairPlaySessionManager.logger = Logger( + OSLog( + subsystem: "com.mux.player", + category: "CK" + ) + ) + } + + func disableLogging() { + self.abrLogger = Logger( + .disabled + ) + self.diagnosticsLogger = Logger( + .disabled + ) + self.fairPlaySessionManager.logger = Logger( + .disabled + ) + } + + func registerPlayerItem( + _ playerItem: AVPlayerItem, + playbackID: String, + playbackOptions: PlaybackOptions + ) { + // as? AVURLAsset check should never fail + if case .drm = playbackOptions.playbackPolicy, + let urlAsset = playerItem.asset as? AVURLAsset { + fairPlaySessionManager.registerPlaybackOptions( + playbackOptions, + for: playbackID + ) + // asset must be attached as early as possible to avoid crashes when attaching later + fairPlaySessionManager.addContentKeyRecipient( + urlAsset + ) + } + } + func registerPlayerLayer( playerLayer: AVPlayerLayer, monitoringOptions: MonitoringOptions, @@ -166,3 +245,22 @@ class PlayerSDK { } } } + +// MARK extension for observations for DRM +extension PlayerSDK { + func observePlayerForDRM(_ player: AVPlayer) { + keyValueObservation.register( + player, + for: \AVPlayer.currentItem, + options: [.old, .new] + ) { player, change in + if let oldAsset = change.oldValue??.asset as? AVURLAsset { + PlayerSDK.shared.fairPlaySessionManager.removeContentKeyRecipient(oldAsset) + } + } + } + + func stopObservingPlayerForDrm(_ player: AVPlayer) { + keyValueObservation.unregister(player) + } +} diff --git a/Sources/MuxPlayerSwift/InternalExtensions/AVPlayerItem+Mux.swift b/Sources/MuxPlayerSwift/InternalExtensions/AVPlayerItem+Mux.swift index 16be1eee..ff8c4014 100644 --- a/Sources/MuxPlayerSwift/InternalExtensions/AVPlayerItem+Mux.swift +++ b/Sources/MuxPlayerSwift/InternalExtensions/AVPlayerItem+Mux.swift @@ -13,195 +13,6 @@ internal enum PlaybackURLConstants { static let reverseProxyPort = Int(1234) } -internal extension URLComponents { - init( - playbackID: String, - playbackOptions: PlaybackOptions - ) { - self.init() - self.scheme = "https" - - if let customDomain = playbackOptions.customDomain { - self.host = "stream.\(customDomain)" - } else { - self.host = "stream.mux.com" - } - - self.path = "/\(playbackID).m3u8" - - if case PlaybackOptions.PlaybackPolicy.public( - let publicPlaybackOptions - ) = playbackOptions.playbackPolicy { - var queryItems: [URLQueryItem] = [] - - if publicPlaybackOptions.useRedundantStreams { - queryItems.append( - URLQueryItem( - name: "redundant_streams", - value: "true" - ) - ) - } - - if publicPlaybackOptions.maximumResolutionTier != .default { - queryItems.append( - URLQueryItem( - name: "max_resolution", - value: publicPlaybackOptions.maximumResolutionTier.queryValue - ) - ) - } - - if publicPlaybackOptions.minimumResolutionTier != .default { - queryItems.append( - URLQueryItem( - name: "min_resolution", - value: publicPlaybackOptions.minimumResolutionTier.queryValue - ) - ) - } - - if publicPlaybackOptions.renditionOrder != .default { - queryItems.append( - URLQueryItem( - name: "rendition_order", - value: publicPlaybackOptions.renditionOrder.queryValue - ) - ) - } - - self.queryItems = queryItems - } else if case PlaybackOptions.PlaybackPolicy.signed(let signedPlaybackOptions) = playbackOptions.playbackPolicy { - - var queryItems: [URLQueryItem] = [] - - queryItems.append( - URLQueryItem( - name: "token", - value: signedPlaybackOptions.playbackToken - ) - ) - - self.queryItems = queryItems - - } - - let isReverseProxyEnabled = playbackOptions.enableSmartCache - - if isReverseProxyEnabled { - // TODO: clean up - self.queryItems = (self.queryItems ?? []) + [ - URLQueryItem( - name: "__hls_origin_url", - value: self.url!.absoluteString - ) - ] - - // TODO: currently enables reverse proxying unless caching is disabled - self.scheme = PlaybackURLConstants.reverseProxyScheme - self.host = PlaybackURLConstants.reverseProxyHost - self.port = PlaybackURLConstants.reverseProxyPort - } - - } -} - -fileprivate func makePlaybackURL( - playbackID: String, - playbackOptions: PlaybackOptions -) -> URL { - - var components = URLComponents() - - components.scheme = "https" - - if let customDomain = playbackOptions.customDomain { - components.host = "stream.\(customDomain)" - } else { - components.host = "stream.mux.com" - } - - components.path = "/\(playbackID).m3u8" - - if case PlaybackOptions.PlaybackPolicy.public(let publicPlaybackOptions) = playbackOptions.playbackPolicy { - var queryItems: [URLQueryItem] = [] - - if publicPlaybackOptions.useRedundantStreams { - queryItems.append( - URLQueryItem( - name: "redundant_streams", - value: "true" - ) - ) - } - - if publicPlaybackOptions.maximumResolutionTier != .default { - queryItems.append( - URLQueryItem( - name: "max_resolution", - value: publicPlaybackOptions.maximumResolutionTier.queryValue - ) - ) - } - - if publicPlaybackOptions.minimumResolutionTier != .default { - queryItems.append( - URLQueryItem( - name: "min_resolution", - value: publicPlaybackOptions.minimumResolutionTier.queryValue - ) - ) - } - - if publicPlaybackOptions.renditionOrder != .default { - queryItems.append( - URLQueryItem( - name: "rendition_order", - value: publicPlaybackOptions.renditionOrder.queryValue - ) - ) - } - - components.queryItems = queryItems - } else if case PlaybackOptions.PlaybackPolicy.signed(let signedPlaybackOptions) = playbackOptions.playbackPolicy { - - var queryItems: [URLQueryItem] = [] - - queryItems.append( - URLQueryItem( - name: "token", - value: signedPlaybackOptions.playbackToken - ) - ) - - components.queryItems = queryItems - - } - - let isReverseProxyEnabled = playbackOptions.enableSmartCache - - if isReverseProxyEnabled { - // TODO: clean up - components.queryItems = (components.queryItems ?? []) + [ - URLQueryItem( - name: "__hls_origin_url", - value: components.url!.absoluteString - ) - ] - - // TODO: currently enables reverse proxying unless caching is disabled - components.scheme = PlaybackURLConstants.reverseProxyScheme - components.host = PlaybackURLConstants.reverseProxyHost - components.port = PlaybackURLConstants.reverseProxyPort - } - - guard let playbackURL = components.url else { - preconditionFailure("Invalid playback URL components") - } - - return playbackURL -} - internal extension AVPlayerItem { // Initializes a player item with a playback URL that @@ -232,7 +43,8 @@ internal extension AVPlayerItem { playbackID: String, playbackOptions: PlaybackOptions ) { - + // Create a new `AVAsset` that has been prepared + // for playback guard let playbackURL = URLComponents( playbackID: playbackID, playbackOptions: playbackOptions @@ -240,6 +52,18 @@ internal extension AVPlayerItem { preconditionFailure("Invalid playback URL components") } - self.init(url: playbackURL) + let asset = AVURLAsset( + url: playbackURL + ) + + self.init( + asset: asset + ) + + PlayerSDK.shared.registerPlayerItem( + self, + playbackID: playbackID, + playbackOptions: playbackOptions + ) } } diff --git a/Sources/MuxPlayerSwift/InternalExtensions/URLComponents+Mux.swift b/Sources/MuxPlayerSwift/InternalExtensions/URLComponents+Mux.swift new file mode 100644 index 00000000..ecd96b45 --- /dev/null +++ b/Sources/MuxPlayerSwift/InternalExtensions/URLComponents+Mux.swift @@ -0,0 +1,164 @@ +// +// URLComponents+Mux.swift +// + +import Foundation + +internal extension URLComponents { + + // MARK: - Playback URL Construction + + init( + playbackID: String, + playbackOptions: PlaybackOptions + ) { + self.init() + self.scheme = "https" + + self.host = "stream.\(playbackOptions.rootDomain())" + self.path = "/\(playbackID).m3u8" + + if case PlaybackOptions.PlaybackPolicy.public( + let publicPlaybackOptions + ) = playbackOptions.playbackPolicy { + var queryItems: [URLQueryItem] = [] + + if publicPlaybackOptions.useRedundantStreams { + queryItems.append( + URLQueryItem( + name: "redundant_streams", + value: "true" + ) + ) + } + + if publicPlaybackOptions.maximumResolutionTier != .default { + queryItems.append( + URLQueryItem( + name: "max_resolution", + value: publicPlaybackOptions.maximumResolutionTier.queryValue + ) + ) + } + + if publicPlaybackOptions.minimumResolutionTier != .default { + queryItems.append( + URLQueryItem( + name: "min_resolution", + value: publicPlaybackOptions.minimumResolutionTier.queryValue + ) + ) + } + + if publicPlaybackOptions.renditionOrder != .default { + queryItems.append( + URLQueryItem( + name: "rendition_order", + value: publicPlaybackOptions.renditionOrder.queryValue + ) + ) + } + + self.queryItems = queryItems + + } else if case PlaybackOptions.PlaybackPolicy.signed(let signedPlaybackOptions) = playbackOptions.playbackPolicy { + + var queryItems: [URLQueryItem] = [] + + queryItems.append( + URLQueryItem( + name: "token", + value: signedPlaybackOptions.playbackToken + ) + ) + + self.queryItems = queryItems + + } else if case PlaybackOptions.PlaybackPolicy.drm(let drmPlaybackOptions) = playbackOptions.playbackPolicy { + + var queryItems: [URLQueryItem] = [] + + queryItems.append( + URLQueryItem( + name: "token", + value: drmPlaybackOptions.playbackToken + ) + ) + + self.queryItems = queryItems + + } + + let isReverseProxyEnabled = playbackOptions.enableSmartCache + + if isReverseProxyEnabled { + // TODO: clean up + self.queryItems = (self.queryItems ?? []) + [ + URLQueryItem( + name: "__hls_origin_url", + value: self.url!.absoluteString + ) + ] + + // TODO: currently enables reverse proxying unless caching is disabled + self.scheme = PlaybackURLConstants.reverseProxyScheme + self.host = PlaybackURLConstants.reverseProxyHost + self.port = PlaybackURLConstants.reverseProxyPort + } + } + + // MARK: - License URL Construction + + // Generates an authenticated URL for retrieving a FairPlay + // content key context (CKC). Generically referred to in + // the Mux API as a license. + init( + playbackID: String, + drmToken: String, + licenseHostSuffix: String + ) { + self.init() + self.scheme = "https" + + self.host = "license.\(licenseHostSuffix)" + self.path = "/license/fairplay/\(playbackID)" + + self.queryItems = [ + URLQueryItem( + name: "token", + value: drmToken + ) + ] + } + + // Generates an authenticated URL for retrieving a FairPlay + // application certificate. + init( + playbackID: String, + drmToken: String, + applicationCertificateHostSuffix: String + ) { + self.init() + self.scheme = "https" + + self.host = "license.\(applicationCertificateHostSuffix)" + self.path = "/appcert/fairplay/\(playbackID)" + + self.queryItems = [ + URLQueryItem( + name: "token", + value: drmToken + ) + ] + } + + // MARK: - Helper Methods + + func findQueryValue(key: String) -> String? { + return self.queryItems? + .first(where: { + $0.name.lowercased() == key.lowercased() + })? + .value + } +} diff --git a/Sources/MuxPlayerSwift/PublicAPI/Extensions/AVPlayerViewController+Mux.swift b/Sources/MuxPlayerSwift/PublicAPI/Extensions/AVPlayerViewController+Mux.swift index 8b980bf1..723c96a3 100644 --- a/Sources/MuxPlayerSwift/PublicAPI/Extensions/AVPlayerViewController+Mux.swift +++ b/Sources/MuxPlayerSwift/PublicAPI/Extensions/AVPlayerViewController+Mux.swift @@ -75,7 +75,7 @@ extension AVPlayerViewController { playbackID: playbackID, playbackOptions: playbackOptions ) - + let player = AVPlayer(playerItem: playerItem) self.player = player diff --git a/Sources/MuxPlayerSwift/PublicAPI/Options/PlaybackOptions.swift b/Sources/MuxPlayerSwift/PublicAPI/Options/PlaybackOptions.swift index cf451440..1f059473 100644 --- a/Sources/MuxPlayerSwift/PublicAPI/Options/PlaybackOptions.swift +++ b/Sources/MuxPlayerSwift/PublicAPI/Options/PlaybackOptions.swift @@ -139,10 +139,16 @@ public struct PlaybackOptions { struct SignedPlaybackOptions { var playbackToken: String } + + struct DRMPlaybackOptions { + var playbackToken: String + var drmToken: String + } enum PlaybackPolicy { case `public`(PublicPlaybackOptions) case signed(SignedPlaybackOptions) + case drm(DRMPlaybackOptions) } var playbackPolicy: PlaybackPolicy @@ -153,6 +159,8 @@ public struct PlaybackOptions { } extension PlaybackOptions { + + // MARK: - Initializers /// Initializes playback options for a public playback ID /// - Parameters: @@ -290,8 +298,8 @@ extension PlaybackOptions { ) self.enableSmartCache = false } - - /// Initializes playback options with a + + /// Initializes playback options for use with a signed playback token /// signed playback token /// - Parameter playbackToken: JSON web token signed /// with a signing key @@ -299,10 +307,27 @@ extension PlaybackOptions { playbackToken: String ) { self.playbackPolicy = .signed( - SignedPlaybackOptions( - playbackToken: playbackToken + SignedPlaybackOptions(playbackToken: playbackToken) + ) + } + + /// Initializes playback options for use with Mux Video DRM + /// - Parameter playbackToken: JSON web token signed + /// with a signing key + /// - Parameter drmToken: JSON web token for DRM playback + public init( + playbackToken: String, + drmToken: String, + customDomain: String? = nil + ) { + self.playbackPolicy = .drm( + DRMPlaybackOptions( + playbackToken: playbackToken, + drmToken: drmToken ) ) + self.customDomain = customDomain + self.enableSmartCache = false } /// Initializes playback options with a @@ -329,4 +354,13 @@ extension PlaybackOptions { ) ) } + + // MARK: Internal helpers + + /// Gets the root domain to be used when constructing URLs for playback, keys, etc. + /// If there is a custom domain, this function returns that value, otherwise it returns the + /// default `mux.com` + internal func rootDomain() -> String { + return customDomain ?? "mux.com" + } } diff --git a/Sources/MuxPlayerSwift/PublicAPI/Version/SemanticVersion.swift b/Sources/MuxPlayerSwift/PublicAPI/Version/SemanticVersion.swift index 42bfc45f..20d32e1b 100644 --- a/Sources/MuxPlayerSwift/PublicAPI/Version/SemanticVersion.swift +++ b/Sources/MuxPlayerSwift/PublicAPI/Version/SemanticVersion.swift @@ -11,7 +11,7 @@ public struct SemanticVersion { public static let major = 1 /// Minor version component. - public static let minor = 0 + public static let minor = 1 /// Patch version component. public static let patch = 0 diff --git a/Tests/MuxPlayerSwift/FairPlay/ContentKeySessionDelegateTests.swift b/Tests/MuxPlayerSwift/FairPlay/ContentKeySessionDelegateTests.swift new file mode 100644 index 00000000..baf0a97d --- /dev/null +++ b/Tests/MuxPlayerSwift/FairPlay/ContentKeySessionDelegateTests.swift @@ -0,0 +1,188 @@ +// +// ContentKeySessionDelegateTests.swift +// +// +// Created by Emily Dixon on 5/7/24. +// + +import Foundation +import XCTest +@testable import MuxPlayerSwift + +class ContentKeySessionDelegateTests : XCTestCase { + + var testPlaybackOptionsRegistry: TestPlaybackOptionsRegistry! + var testCredentialClient: TestFairPlayStreamingSessionCredentialClient! + var testSessionManager: TestFairPlayStreamingSessionManager! + + // object under test + var contentKeySessionDelegate: ContentKeySessionDelegate< + TestFairPlayStreamingSessionManager + >! + + override func setUp() async throws { + setUpForSuccess() + } + + private func setUpForFailure(error: any Error) { + testCredentialClient = TestFairPlayStreamingSessionCredentialClient( + failsWith: error + ) + testPlaybackOptionsRegistry = TestPlaybackOptionsRegistry() + testSessionManager = TestFairPlayStreamingSessionManager( + credentialClient: testCredentialClient, + optionsRegistry: testPlaybackOptionsRegistry + ) + + contentKeySessionDelegate = ContentKeySessionDelegate( + sessionManager: testSessionManager + ) + } + + private func setUpForSuccess() { + testCredentialClient = TestFairPlayStreamingSessionCredentialClient( + fakeCert: "default fake cert".data(using: .utf8)!, + fakeLicense: "default fake license".data(using: .utf8)! + ) + testPlaybackOptionsRegistry = TestPlaybackOptionsRegistry() + + testSessionManager = TestFairPlayStreamingSessionManager( + credentialClient: testCredentialClient, + optionsRegistry: testPlaybackOptionsRegistry + ) + + contentKeySessionDelegate = ContentKeySessionDelegate( + sessionManager: testSessionManager + ) + } + + private func makeFakeSkdUrl(fakePlaybackID: String) -> String { + return "skd://fake.domain/?playbackId=\(fakePlaybackID)&token=unrelated-to-test" + } + + private func makeFakeSkdUrlIncorrect() -> String { + return "skd://fake.domain/?token=unrelated-to-test" + } + + func testParsePlaybackId() throws { + let fakePlaybackID = "fake-playback-id" + let fakeKeyUri = URL( + string: makeFakeSkdUrl(fakePlaybackID: fakePlaybackID) + )! + + let foundPlaybackID = contentKeySessionDelegate.parsePlaybackId( + fromSkdLocation: fakeKeyUri + ) + + XCTAssertEqual(fakePlaybackID, foundPlaybackID) + } + + func testKeyRequestNoPlaybackId() throws { + let mockRequest = MockKeyRequest( + fakeIdentifier: makeFakeSkdUrl( + fakePlaybackID: makeFakeSkdUrlIncorrect() + ) + ) + + contentKeySessionDelegate.handleContentKeyRequest(request: mockRequest) + + XCTAssertTrue( + mockRequest.verifyWasCalled( + funcName: "processContentKeyResponseError" + ) + ) + XCTAssertTrue( + mockRequest.verifyNotCalled(funcName: "makeStreamingContentKeyRequestData") + ) + } + + func testKeyRequestCertError() throws { + setUpForFailure(error: FakeError(tag: "fake error")) + let mockRequest = MockKeyRequest( + fakeIdentifier: makeFakeSkdUrl(fakePlaybackID: "fake-playback") + ) + + contentKeySessionDelegate.handleContentKeyRequest(request: mockRequest) + XCTAssertTrue( + mockRequest.verifyWasCalled( + funcName: "processContentKeyResponseError" + ) + ) + XCTAssertTrue( + mockRequest.verifyNotCalled(funcName: "makeStreamingContentKeyRequestData") + ) + } + + func testKeyRequestHappyPath() throws { + let mockRequest = MockKeyRequest( + fakeIdentifier: makeFakeSkdUrl( + fakePlaybackID: "fake-playback" + ) + ) + testPlaybackOptionsRegistry.registerPlaybackOptions( + PlaybackOptions(playbackToken: "playback-token", drmToken: "drm-token"), + for: "fake-playback" + ) + + contentKeySessionDelegate.handleContentKeyRequest(request: mockRequest) + + XCTAssertTrue( + mockRequest.verifyNotCalled(funcName: "processContentKeyResponseError") + ) + XCTAssertTrue( + mockRequest.verifyWasCalled(funcName: "makeStreamingContentKeyRequestData") + ) + } + + func testSPCForCKCFailedLicense() throws { + setUpForFailure(error: FakeError(tag: "fake error")) + let mockRequest = MockKeyRequest( + fakeIdentifier: makeFakeSkdUrl(fakePlaybackID: "fake-playback") + ) + + contentKeySessionDelegate.handleSpcObtainedFromCDM( + spcData: "fake-spc-data".data(using: .utf8)!, + playbackID: "fake-playback", + drmToken: "fake-drm-token", + rootDomain: "mux.com", + request: mockRequest + ) + + XCTAssertTrue( + mockRequest.verifyWasCalled( + funcName: "processContentKeyResponseError" + ) + ) + XCTAssertTrue( + mockRequest.verifyNotCalled(funcName: "processContentKeyResponse") + ) + } + + func testSPCForCKCHappyPath() throws { + let mockRequest = MockKeyRequest( + fakeIdentifier: makeFakeSkdUrl( + fakePlaybackID: "fake-playback" + ) + ) + testPlaybackOptionsRegistry.registerPlaybackOptions( + PlaybackOptions(playbackToken: "playback-token", drmToken: "drm-token"), + for: "fake-playback" + ) + + contentKeySessionDelegate.handleSpcObtainedFromCDM( + spcData: "fake-spc-data".data(using: .utf8)!, + playbackID: "fake-playback", + drmToken: "fake-drm-token", + rootDomain: "mux.com", + request: mockRequest + ) + + XCTAssertTrue( + mockRequest.verifyNotCalled(funcName: "processContentKeyResponseError") + ) + XCTAssertTrue( + mockRequest.verifyWasCalled(funcName: "processContentKeyResponse") + ) + } + +} diff --git a/Tests/MuxPlayerSwift/FairPlay/FairPlaySessionManagerTests.swift b/Tests/MuxPlayerSwift/FairPlay/FairPlaySessionManagerTests.swift new file mode 100644 index 00000000..482be2dc --- /dev/null +++ b/Tests/MuxPlayerSwift/FairPlay/FairPlaySessionManagerTests.swift @@ -0,0 +1,641 @@ +// +// FairPlaySessionManagerTests.swift +// +// +// Created by Emily Dixon on 5/2/24. +// + +import Foundation +import XCTest +import AVKit +import os +@testable import MuxPlayerSwift + +class FairPlaySessionManagerTests : XCTestCase { + + // mocks + private var mockURLSession: URLSession! + + + // object under test + private var sessionManager: FairPlayStreamingSessionManager! + + override func setUp() { + super.setUp() + let mockURLSessionConfig = URLSessionConfiguration.default + mockURLSessionConfig.protocolClasses = [MockURLProtocol.self] + self.mockURLSession = URLSession.init(configuration: mockURLSessionConfig) + let session = TestContentKeySession() + let defaultFairPlaySessionManager = DefaultFairPlayStreamingSessionManager( + // .clearKey is used because .fairPlay requires a physical device + contentKeySession: session, + urlSession: mockURLSession + ) + self.sessionManager = defaultFairPlaySessionManager + defaultFairPlaySessionManager.sessionDelegate = ContentKeySessionDelegate( + sessionManager: defaultFairPlaySessionManager + ) + + } + + func testDefaultLicenseURL() throws { + let fakePlaybackId = "abc" + let fakeDrmToken = "fake_drm_token" + let fakeLicenseDomain = PlaybackOptions().rootDomain() + + let licenseURL = try XCTUnwrap( + URLComponents( + playbackID: fakePlaybackId, + drmToken: fakeDrmToken, + licenseHostSuffix: fakeLicenseDomain + ).url + ) + XCTAssertEqual( + licenseURL.absoluteString, + "https://license.mux.com/license/fairplay/abc?token=fake_drm_token" + ) + } + + func testCustomLicenseURL() throws { + let fakePlaybackId = "abc" + let fakeDrmToken = "fake_drm_token" + let fakeLicenseDomain = "fake.domain.xyz" + + let licenseURL = try XCTUnwrap( + URLComponents( + playbackID: fakePlaybackId, + drmToken: fakeDrmToken, + licenseHostSuffix: fakeLicenseDomain + ).url + ) + + XCTAssertEqual( + licenseURL.absoluteString, + "https://license.fake.domain.xyz/license/fairplay/abc?token=fake_drm_token" + ) + } + + func testMakeAppCertificateUrl() throws { + let fakePlaybackId = "abc" + let fakeDrmToken = "fake_drm_token" + let applicationCertificateDomain = "fake.domain.xyz" + + let licenseURL = try XCTUnwrap( + URLComponents( + playbackID: fakePlaybackId, + drmToken: fakeDrmToken, + applicationCertificateHostSuffix: applicationCertificateDomain + ).url + ) + + XCTAssertEqual( + "https://license.fake.domain.xyz/appcert/fairplay/abc?token=fake_drm_token", + licenseURL.absoluteString + ) + } + + func testAppCertificateRequestBody() throws { + let fakeRootDomain = "custom.domain.com" + let fakePlaybackId = "fake_playback_id" + let fakeDrmToken = "fake_drm_token" + + var urlRequest: URLRequest! + MockURLProtocol.requestHandler = { request in + urlRequest = request + // response is not part of this test + return (HTTPURLResponse(), nil) + } + + let requestEnds = XCTestExpectation(description: "request ends") + sessionManager.requestCertificate( + fromDomain: fakeRootDomain, + playbackID: fakePlaybackId, + drmToken: fakeDrmToken + ) { result in + // we recorded the request so we should be ok + requestEnds.fulfill() + } + wait(for: [requestEnds]) + + let urlComponents = URLComponents(string: urlRequest.url!.absoluteString)! + XCTAssertNotNil(urlComponents.queryItems) + XCTAssert(urlComponents.queryItems!.count > 0) + + let tokenParam = urlComponents.queryItems!.first { it in it.name == "token"} + let playbackID = urlRequest.url!.lastPathComponent + + XCTAssertNotNil(tokenParam) + XCTAssertEqual(tokenParam?.name, "token") + XCTAssertEqual(tokenParam?.value, fakeDrmToken) + + XCTAssertEqual(playbackID, fakePlaybackId) + + XCTAssertEqual(urlRequest.httpMethod, "GET") + // note: url tested using testMakeAppCertificateURL + } + + func testLicenseRequestBody() throws { + let fakeRootDomain = "custom.domain.com" + let fakePlaybackId = "fake_playback_id" + let fakeDrmToken = "fake_drm_token" + // real SPC's are opaque binary to us, the fake one can be whatever + let fakeSpcData = "fake-SPC-binary-data".data(using: .utf8)! + + var urlRequest: URLRequest! + MockURLProtocol.requestHandler = { request in + urlRequest = request + + // response is not part of this test + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + return (response, "fake ckc data".data(using: .utf8)) + } + + let requestEnds = XCTestExpectation(description: "request ends") + sessionManager.requestLicense( + spcData: fakeSpcData, + playbackID: fakePlaybackId, + drmToken: fakeDrmToken, + rootDomain: fakeRootDomain, + offline: false + ) { result in + // we recorded the request so we should be ok + requestEnds.fulfill() + } + wait(for: [requestEnds]) + + let urlComponents = URLComponents(string: urlRequest.url!.absoluteString)! + XCTAssertNotNil(urlComponents.queryItems) + XCTAssert(urlComponents.queryItems!.count > 0) + + let tokenParam = urlComponents.queryItems!.first { it in it.name == "token"} + let playbackID = urlRequest.url!.lastPathComponent + + XCTAssertNotNil(tokenParam) + XCTAssertEqual(tokenParam?.name, "token") + XCTAssertEqual(tokenParam?.value, fakeDrmToken) + + XCTAssertEqual(playbackID, fakePlaybackId) + + // unfortunately we can't test the body for some reason, it's always nil even + // when intercepting with URLProtocol + //XCTAssertEqual(urlRequest.httpBody, fakeSpcData) + + XCTAssertEqual(urlRequest.httpMethod, "POST") + + let headers = urlRequest.allHTTPHeaderFields + guard let headers = headers, headers.count > 0 else { + XCTFail("Request for License/CKC must have length and content type") + return + } + let contentLengthHeader = headers["Content-Length"] + let contentTypeHeader = headers["Content-Type"] + XCTAssertEqual(Int(contentLengthHeader!)!, fakeSpcData.count) + XCTAssertEqual(contentTypeHeader, "application/octet-stream") + } + func testRequestCertificateSuccess() throws { + let fakeRootDomain = "custom.domain.com" + let fakePlaybackId = "fake_playback_id" + let fakeDrmToken = "fake_drm_token" + // real app certs are opaque binary to us, the fake one can be whatever + let fakeAppCert = "fake-application-cert-binary-data".data(using: .utf8) + + let requestSuccess = XCTestExpectation(description: "request certificate successfully") + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + + return (response, fakeAppCert) + } + + var foundAppCert: Data? + sessionManager.requestCertificate( + fromDomain: fakeRootDomain, + playbackID: fakePlaybackId, + drmToken: fakeDrmToken + ) { result in + guard let result = try? result.get() else { + XCTFail("Should not report failure for the given request") + return + } + + foundAppCert = result + requestSuccess.fulfill() + } + wait(for: [requestSuccess]) + XCTAssertEqual(foundAppCert, fakeAppCert) + } + + func testRequestCertificateHttpError() throws { + let fakeRootDomain = "custom.domain.com" + let fakePlaybackId = "fake_playback_id" + let fakeDrmToken = "fake_drm_token" + let fakeHTTPStatus = 500 // all codes are handled the same way, by failing + + let requestFails = XCTestExpectation(description: "request certificate successfully") + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: fakeHTTPStatus, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + + // failed requests proxied from our drm vendor have response bodies with + // base64 text, which we should treat as opaque (not parse or decode), + // since can't do anything with them and Cast logs them on the backend + let errorBody = "failed request source text" + let errorData = errorBody.data(using: .utf8) // crashes if processed probably + return ( + response, + errorData + ) + } + + var reqError: Error? + sessionManager.requestCertificate( + fromDomain: fakeRootDomain, + playbackID: fakePlaybackId, + drmToken: fakeDrmToken + ) { result in + do { + _ = try result.get() + XCTFail("failure should have been reported") + } catch { + reqError = error + } + requestFails.fulfill() + + } + wait(for: [requestFails]) + + guard let fpsError = reqError as? FairPlaySessionError else { + XCTFail("Request error was wrong type") + return + } + + if case .httpFailed(let code) = fpsError { + XCTAssertEqual(code, fakeHTTPStatus) + } else { + XCTFail("HTTP failure not reported with .httpFailed()") + } + } + + func testRequestCertificateIOError() throws { + let fakeRootDomain = "custom.domain.com" + let fakePlaybackId = "fake_playback_id" + let fakeDrmToken = "fake_drm_token" + + let requestFails = XCTestExpectation(description: "request certificate successfully") + MockURLProtocol.requestHandler = { request in + throw FakeError() + } + + var reqError: Error? + sessionManager.requestCertificate( + fromDomain: fakeRootDomain, + playbackID: fakePlaybackId, + drmToken: fakeDrmToken + ) { result in + do { + _ = try result.get() + XCTFail("failure should have been reported") + } catch { + reqError = error + } + requestFails.fulfill() + } + wait(for: [requestFails]) + + guard let fpsError = reqError as? FairPlaySessionError else { + XCTFail("Request error was wrong type") + return + } + + guard case .because(_) = fpsError else { + XCTFail("I/O Failure should report a cause") + return + } + + // If we make it here, we succeeded + } + + func testRequestCertificateBlankWithSusStatusCode() throws { + let fakeRootDomain = "custom.domain.com" + let fakePlaybackId = "fake_playback_id" + let fakeDrmToken = "fake_drm_token" + // In this case, there's a successful response but no body + + let requestFails = XCTestExpectation(description: "request certificate suspicious 200/OK should be treated as failure") + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + + return (response, nil) + } + + // Expected behavior: URLTask does something odd, requestCertificate returns error + var reqError: Error? + sessionManager.requestCertificate( + fromDomain: fakeRootDomain, + playbackID: fakePlaybackId, + drmToken: fakeDrmToken + ) { result in + do { + _ = try result.get() + XCTFail("failure should have been reported") + } catch { + reqError = error + requestFails.fulfill() + } + } + wait(for: [requestFails]) + + guard let fpsError = reqError as? FairPlaySessionError else { + XCTFail("Request error was wrong type") + return + } + + guard case .unexpected(_) = fpsError else { + XCTFail("An Unexpected error should be returned") + return + } + } + + func testRequestLicenseSuccess() throws { + let fakeRootDomain = "custom.domain.com" + let fakePlaybackId = "fake_playback_id" + let fakeDrmToken = "fake_drm_token" + let fakeSpcData = "fake-spc-data".data(using: .utf8)! + // to be returned by call under test + let fakeLicense = "fake-license-binary-data".data(using: .utf8) + + let requestSuccess = XCTestExpectation(description: "request license successfully") + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + + return (response, fakeLicense) + } + + var foundAppCert: Data? + sessionManager.requestLicense( + spcData: fakeSpcData, + playbackID: fakePlaybackId, + drmToken: fakeDrmToken, + rootDomain: fakeRootDomain, + offline: false + ) { result in + guard let result = try? result.get() else { + XCTFail("Should not report failure for the given request") + return + } + + foundAppCert = result + requestSuccess.fulfill() + } + wait(for: [requestSuccess]) + XCTAssertEqual(foundAppCert, fakeLicense) + } + + func testLicenseRequestHttpError() throws { + let fakeRootDomain = "custom.domain.com" + let fakePlaybackId = "fake_playback_id" + let fakeDrmToken = "fake_drm_token" + let fakeHTTPStatus = 500 // all codes are handled the same way, by failing + // real SPCs are opaque binary to us, the fake one can be whatever + let fakeSpcData = "fake-spc-data".data(using: .utf8)! + + let requestFails = XCTestExpectation(description: "request certificate successfully") + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: fakeHTTPStatus, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + + // failed requests proxied from our drm vendor have response bodies with + // base64 text, which we should treat as opaque (not parse or decode), + // since we can't do anything with them and Cast logs them on the backend + let errorBody = "failed request source text" + let errorData = errorBody.data(using: .utf8) // crashes if processed probably + return ( + response, + errorData + ) + } + + var reqError: Error? + sessionManager.requestLicense( + spcData: fakeSpcData, + playbackID: fakePlaybackId, + drmToken: fakeDrmToken, + rootDomain: fakeRootDomain, + offline: false + ) { result in + do { + _ = try result.get() + XCTFail("failure should have been reported") + } catch { + reqError = error + } + requestFails.fulfill() + + } + wait(for: [requestFails]) + + guard let fpsError = reqError as? FairPlaySessionError else { + XCTFail("Request error was wrong type") + return + } + + if case .httpFailed(let code) = fpsError { + XCTAssertEqual(code, fakeHTTPStatus) + } else { + XCTFail("HTTP failure not reported with .httpFailed()") + } + } + + func testRequestLicenseIOError() throws { + let fakeRootDomain = "custom.domain.com" + let fakePlaybackId = "fake_playback_id" + let fakeDrmToken = "fake_drm_token" + let fakeSpcData = "fake-spc-data".data(using: .utf8)! + + let requestFails = XCTestExpectation(description: "request certificate successfully") + MockURLProtocol.requestHandler = { request in + throw FakeError() + } + + var reqError: Error? + sessionManager.requestLicense( + spcData: fakeSpcData, + playbackID: fakePlaybackId, + drmToken: fakeDrmToken, + rootDomain: fakeRootDomain, + offline: false + ) { result in + do { + let data = try result.get() + XCTFail("failure should have been reported, but got \(String(describing: data))") + } catch { + reqError = error + } + requestFails.fulfill() + } + wait(for: [requestFails]) + + guard let fpsError = reqError as? FairPlaySessionError else { + XCTFail("Request error was wrong type") + return + } + + guard case .because(_) = fpsError else { + XCTFail("I/O Failure should report a cause") + return + } + + // If we make it here, we succeeded + } + + func testRequestLicenseBlankWithSusStatusCode() throws { + let fakeRootDomain = "custom.domain.com" + let fakePlaybackId = "fake_playback_id" + let fakeDrmToken = "fake_drm_token" + // In this case, there's a successful response but no body + let fakeSpcData = "fake-spc-data".data(using: .utf8)! + + let requestFails = XCTestExpectation(description: "request certificate suspicious 200/OK should be treated as failure") + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + + return (response, nil) + } + + // Expected behavior: URLTask does something odd, requestCertificate returns error + var reqError: Error? + sessionManager.requestLicense( + spcData: fakeSpcData, + playbackID: fakePlaybackId, + drmToken: fakeDrmToken, + rootDomain: fakeRootDomain, + offline: false + ) { result in + do { + _ = try result.get() + XCTFail("failure should have been reported") + } catch { + reqError = error + } + requestFails.fulfill() + } + wait(for: [requestFails]) + + guard let fpsError = reqError as? FairPlaySessionError else { + XCTFail("Request error was wrong type") + return + } + + guard case .unexpected(_) = fpsError else { + XCTFail("unexpected failure should be returned") + return + } + } + + func testPlaybackOptionsRegistered() throws { + + #if DEBUG + let mockURLSessionConfig = URLSessionConfiguration.default + mockURLSessionConfig.protocolClasses = [MockURLProtocol.self] + self.mockURLSession = URLSession.init(configuration: mockURLSessionConfig) + // .clearKey is used because .fairPlay requires a physical device + let session = AVContentKeySession( + keySystem: .clearKey + ) + let defaultFairPlaySessionManager = DefaultFairPlayStreamingSessionManager( + contentKeySession: session, + urlSession: mockURLSession + ) + self.sessionManager = defaultFairPlaySessionManager + let sessionDelegate = ContentKeySessionDelegate( + sessionManager: defaultFairPlaySessionManager + ) + defaultFairPlaySessionManager.sessionDelegate = sessionDelegate + + let fakeLicense = "fake-license-binary-data".data(using: .utf8) + let fakeAppCert = "fake-application-cert-binary-data".data(using: .utf8) + MockURLProtocol.requestHandler = { request in + + guard let url = request.url else { + fatalError() + } + + if (url.absoluteString.contains("appcert")) { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + + return (response, fakeAppCert) + } else { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + + + return (response, fakeLicense) + } + } + + + PlayerSDK.shared = PlayerSDK( + fairPlayStreamingSessionManager: defaultFairPlaySessionManager + ) + + let _ = AVPlayerItem( + playbackID: "abc", + playbackOptions: PlaybackOptions( + playbackToken: "def", + drmToken: "ghi" + ) + ) + + XCTAssertEqual( + defaultFairPlaySessionManager.playbackOptionsByPlaybackID.count, + 1 + ) + #else + XCTExpectFailure( + "This test can only be run under a debug build configuration" + ) + XCTAssert(false) + #endif + } +} diff --git a/Tests/MuxPlayerSwift/Helpers/FakeError.swift b/Tests/MuxPlayerSwift/Helpers/FakeError.swift new file mode 100644 index 00000000..3681831e --- /dev/null +++ b/Tests/MuxPlayerSwift/Helpers/FakeError.swift @@ -0,0 +1,12 @@ +// +// File.swift +// +// +// Created by Emily Dixon on 5/3/24. +// + +import Foundation + +struct FakeError : Error, Equatable { + var tag: String? +} diff --git a/Tests/MuxPlayerSwift/Helpers/MockKeyRequest.swift b/Tests/MuxPlayerSwift/Helpers/MockKeyRequest.swift new file mode 100644 index 00000000..ff3a7e4b --- /dev/null +++ b/Tests/MuxPlayerSwift/Helpers/MockKeyRequest.swift @@ -0,0 +1,82 @@ +// +// MockKeyRequest.swift +// +// +// Created by Emily Dixon on 5/7/24. +// + +import Foundation +import AVFoundation +@testable import MuxPlayerSwift + +/// Mock ``KeyRequest`` with some basic recording & verification +class MockKeyRequest : KeyRequest { + // our fake 'request' just records calls and args + typealias InnerRequest = [(String, [Any?])] + + private var fakeRequest: InnerRequest = [] + private let fakeIdentifier: Any + + // MARK: Protocol impl + + var identifier: Any? { + get { + return fakeIdentifier + } + } + + func makeContentKeyResponse(data: Data) -> AVContentKeyResponse { + // can't use the fairplay data in tests + return AVContentKeyResponse(authorizationTokenData: "fake-token".data(using: .utf8)!) + } + + + func processContentKeyResponse(_ response: AVContentKeyResponse) { + fakeRequest.append(("processContentKeyResponse", [response])) + } + + func processContentKeyResponseError(_ error: any Error) { + fakeRequest.append(("processContentKeyResponseError", [error])) + } + + func makeStreamingContentKeyRequestData( + forApp appIdentifier: Data, + contentIdentifier: Data?, + options: [String : Any]? = nil, + completionHandler handler: @escaping (Data?, (any Error)?) -> Void + ) { + let funcName = "makeStreamingContentKeyRequestData" + let args: [Any?] = [ + appIdentifier, + contentIdentifier as Any, + options as Any, + handler + ] as [Any?] + + fakeRequest.append((funcName, args)) + } + + // MARK: verificaitons + + /// Verifies that the given method was called the given number of times + /// This can be enough for situations where the arg values don't matter + /// or where they'd be pretty obvious. + /// To verify args, use ``calls`` + func verifyWasCalled(funcName: String, times: Int = 1) -> Bool { + return fakeRequest.filter{ (f, _) in f == funcName }.count == times + } + + func verifyNotCalled(funcName: String) -> Bool { + return verifyWasCalled(funcName: funcName, times: 0) + } + + var calls: [(String, [Any?])] { + get { + return fakeRequest + } + } + + init(fakeIdentifier: String = "fake-identifier") { + self.fakeIdentifier = fakeIdentifier + } +} diff --git a/Tests/MuxPlayerSwift/Helpers/MockURLProtocol.swift b/Tests/MuxPlayerSwift/Helpers/MockURLProtocol.swift new file mode 100644 index 00000000..c219a225 --- /dev/null +++ b/Tests/MuxPlayerSwift/Helpers/MockURLProtocol.swift @@ -0,0 +1,49 @@ +// +// MockURLProtocol.swift +// +// +// Created by Emily Dixon on 5/2/24. +// + +import Foundation + +/// Mock URL Protocol for canned responses. +/// https://medium.com/@dhawaldawar/how-to-mock-urlsession-using-urlprotocol-8b74f389a67a +class MockURLProtocol: URLProtocol { + + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))? + static var lastRequest: URLRequest? + + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + lastRequest = request + return request + } + + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + fatalError("Handler is unavailable.") + } + + do { + let (response, data) = try handler(request) + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + + if let data = data { + client?.urlProtocol(self, didLoad: data) + } + + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() { + // This is called if the request gets canceled or completed. + } +} diff --git a/Tests/MuxPlayerSwift/Helpers/TestContentKeySession.swift b/Tests/MuxPlayerSwift/Helpers/TestContentKeySession.swift new file mode 100644 index 00000000..322c0bf3 --- /dev/null +++ b/Tests/MuxPlayerSwift/Helpers/TestContentKeySession.swift @@ -0,0 +1,37 @@ +// +// TestContentKeySession.swift +// +// +// Created by Emily Dixon on 5/2/24. +// + +import Foundation +import AVKit + +@testable import MuxPlayerSwift + +class TestContentKeySession: ContentKeyProvider { + + var delegate: (any AVContentKeySessionDelegate)? + + var contentKeyRecipients: [any AVContentKeyRecipient] = [] + + func setDelegate( + _ delegate: (any AVContentKeySessionDelegate)?, + queue delegateQueue: dispatch_queue_t? + ) { + self.delegate = delegate + } + + func addContentKeyRecipient(_ recipient: any AVContentKeyRecipient) { + contentKeyRecipients.append(recipient) + } + + func removeContentKeyRecipient(_ recipient: any AVContentKeyRecipient) { + // no-op + } + + init() { + + } +} diff --git a/Tests/MuxPlayerSwift/Helpers/TestFairPlayStreamingSessionCredentialClient.swift b/Tests/MuxPlayerSwift/Helpers/TestFairPlayStreamingSessionCredentialClient.swift new file mode 100644 index 00000000..863f303b --- /dev/null +++ b/Tests/MuxPlayerSwift/Helpers/TestFairPlayStreamingSessionCredentialClient.swift @@ -0,0 +1,61 @@ +// +// TestFairPlayStreamingCredentialsClient.swift +// +// +// Created by Emily Dixon on 5/7/24. +// + +import Foundation +import os +@testable import MuxPlayerSwift + +/// Testing version of the FairPlayStreamingSessionCredentialClient` +/// This version does not interact with the network at all, it just signals +/// sucess or failure as-configured +class TestFairPlayStreamingSessionCredentialClient: FairPlayStreamingSessionCredentialClient { + + private let fakeCert: Data? + private let fakeLicense: Data? + private let failsWith: (any Error)! + + var logger: Logger = Logger( + OSLog( + subsystem: "com.mux.player", + category: "CK" + ) + ) + + func requestCertificate(fromDomain rootDomain: String, playbackID: String, drmToken: String, completion requestCompletion: @escaping (Result) -> Void) { + if let fakeCert = fakeCert { + requestCompletion(Result.success(fakeCert)) + } else { + requestCompletion(Result.failure(failsWith)) + } + } + + func requestLicense(spcData: Data, playbackID: String, drmToken: String, rootDomain: String, offline _: Bool, completion requestCompletion: @escaping (Result) -> Void) { + if let fakeLicense = fakeLicense { + requestCompletion(Result.success(fakeLicense)) + } else { + requestCompletion(Result.failure(failsWith)) + } + } + + convenience init(fakeCert: Data, fakeLicense: Data) { + self.init(fakeCert: fakeCert, fakeLicense: fakeLicense, failsWith: nil) + } + + convenience init(failsWith: any Error) { + self.init(fakeCert: nil, fakeLicense: nil, failsWith: failsWith) + } + + private init( + fakeCert: Data?, + fakeLicense: Data?, + failsWith: (any Error)? + ) { + self.fakeCert = fakeCert + self.fakeLicense = fakeLicense + self.failsWith = failsWith + } +} diff --git a/Tests/MuxPlayerSwift/Helpers/TestFairPlayStreamingSessionManager.swift b/Tests/MuxPlayerSwift/Helpers/TestFairPlayStreamingSessionManager.swift new file mode 100644 index 00000000..583693d5 --- /dev/null +++ b/Tests/MuxPlayerSwift/Helpers/TestFairPlayStreamingSessionManager.swift @@ -0,0 +1,50 @@ +// +// TestFairPlayStreamingManager.swift +// +// +// Created by Emily Dixon on 5/7/24. +// + +import Foundation +import AVFoundation +import os +@testable import MuxPlayerSwift + +class TestFairPlayStreamingSessionManager : FairPlayStreamingSessionCredentialClient & PlaybackOptionsRegistry { + + let credentialClient: FairPlayStreamingSessionCredentialClient + let optionsRegistry: PlaybackOptionsRegistry + + var logger: Logger = Logger( + OSLog( + subsystem: "com.mux.player", + category: "CK" + ) + ) + + func requestCertificate(fromDomain rootDomain: String, playbackID: String, drmToken: String, completion requestCompletion: @escaping (Result) -> Void) { + credentialClient.requestCertificate(fromDomain: rootDomain, playbackID: playbackID, drmToken: drmToken, completion: requestCompletion) + } + + func requestLicense(spcData: Data, playbackID: String, drmToken: String, rootDomain: String, offline: Bool, completion requestCompletion: @escaping (Result) -> Void) { + credentialClient.requestLicense(spcData: spcData, playbackID: playbackID, drmToken: drmToken, rootDomain: rootDomain, offline: offline, completion: requestCompletion) + } + + func registerPlaybackOptions(_ opts: MuxPlayerSwift.PlaybackOptions, for playbackID: String) { + optionsRegistry.registerPlaybackOptions(opts, for: playbackID) + } + + func findRegisteredPlaybackOptions(for playbackID: String) -> MuxPlayerSwift.PlaybackOptions? { + optionsRegistry.findRegisteredPlaybackOptions(for: playbackID) + } + + func unregisterPlaybackOptions(for playbackID: String) { + optionsRegistry.unregisterPlaybackOptions(for: playbackID) + } + + init(credentialClient: any FairPlayStreamingSessionCredentialClient, + optionsRegistry: any PlaybackOptionsRegistry) { + self.credentialClient = credentialClient + self.optionsRegistry = optionsRegistry + } +} diff --git a/Tests/MuxPlayerSwift/Helpers/TestPlaybackOptionsRegistry.swift b/Tests/MuxPlayerSwift/Helpers/TestPlaybackOptionsRegistry.swift new file mode 100644 index 00000000..2a835a83 --- /dev/null +++ b/Tests/MuxPlayerSwift/Helpers/TestPlaybackOptionsRegistry.swift @@ -0,0 +1,28 @@ +// +// TestPlaybackOptionsRegistry.swift +// +// +// Created by Emily Dixon on 5/7/24. +// + +import Foundation +@testable import MuxPlayerSwift + +class TestPlaybackOptionsRegistry : PlaybackOptionsRegistry { + + var options: [String: PlaybackOptions] = [:] + + func registerPlaybackOptions(_ opts: MuxPlayerSwift.PlaybackOptions, for playbackID: String) { + options[playbackID] = opts + } + + func findRegisteredPlaybackOptions(for playbackID: String) -> MuxPlayerSwift.PlaybackOptions? { + return options[playbackID] + } + + func unregisterPlaybackOptions(for playbackID: String) { + options[playbackID] = nil + } + + +}