diff --git a/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample.xcodeproj/project.pbxproj b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample.xcodeproj/project.pbxproj index 44749c78..58df2fcc 100644 --- a/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample.xcodeproj/project.pbxproj +++ b/Examples/MuxPlayerSwiftExample/MuxPlayerSwiftExample.xcodeproj/project.pbxproj @@ -551,7 +551,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = XX95P4Y787; + DEVELOPMENT_TEAM = CX6AHWLHM6; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.mux.player.example.MuxPlayerSwiftExample.MuxPlayerSwiftExampleUITests; @@ -569,7 +569,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = XX95P4Y787; + DEVELOPMENT_TEAM = CX6AHWLHM6; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.mux.player.example.MuxPlayerSwiftExample.MuxPlayerSwiftExampleUITests; diff --git a/Sources/MuxPlayerSwift/Fairplay/ContentKeySessionDelegate.swift b/Sources/MuxPlayerSwift/FairPlay/ContentKeySessionDelegate.swift similarity index 58% rename from Sources/MuxPlayerSwift/Fairplay/ContentKeySessionDelegate.swift rename to Sources/MuxPlayerSwift/FairPlay/ContentKeySessionDelegate.swift index e1e6e28f..67d95cba 100644 --- a/Sources/MuxPlayerSwift/Fairplay/ContentKeySessionDelegate.swift +++ b/Sources/MuxPlayerSwift/FairPlay/ContentKeySessionDelegate.swift @@ -8,42 +8,39 @@ import Foundation import AVFoundation -class ContentKeySessionDelegate : NSObject, AVContentKeySessionDelegate { +class ContentKeySessionDelegate : NSObject, AVContentKeySessionDelegate { + + weak var sessionManager: SessionManager? + + init(sessionManager: SessionManager) { + self.sessionManager = sessionManager + } // MARK: AVContentKeySessionDelegate implementation func contentKeySession(_ session: AVContentKeySession, didProvide keyRequest: AVContentKeyRequest) { - handleContentKeyRequest(session, request: keyRequest) + handleContentKeyRequest(request: DefaultKeyRequest(wrapping: keyRequest)) } func contentKeySession(_ session: AVContentKeySession, didProvideRenewingContentKeyRequest keyRequest: AVContentKeyRequest) { - handleContentKeyRequest(session, request: keyRequest) + handleContentKeyRequest(request: DefaultKeyRequest(wrapping: keyRequest)) } func contentKeySession(_ session: AVContentKeySession, contentKeyRequestDidSucceed keyRequest: AVContentKeyRequest) { // this func intentionally left blank + // TODO: Log more nicely (ie, with a Logger) print("CKC Request Success") } func contentKeySession(_ session: AVContentKeySession, contentKeyRequest keyRequest: AVContentKeyRequest, didFailWithError err: any Error) { + // TODO: Log more nicely (ie, with a Logger) print("CKC Request Failed!!! \(err.localizedDescription)") } - func contentKeySessionContentProtectionSessionIdentifierDidChange(_ session: AVContentKeySession) { - print("Content Key session ID changed apparently") - } - - func contentKeySessionDidGenerateExpiredSessionReport(_ session: AVContentKeySession) { - print("Expired session report generated (whatever that means)") - } - - func contentKeySession(_ session: AVContentKeySession, externalProtectionStatusDidChangeFor contentKey: AVContentKey) { - print("External Protection status changed for a content key sesison") - } - func contentKeySession(_ session: AVContentKeySession, shouldRetry keyRequest: AVContentKeyRequest, reason retryReason: AVContentKeyRequest.RetryReason) -> Bool { - print("===shouldRetry called with reason \(retryReason)") + // TODO: use Logger + print("shouldRetry called with reason \(retryReason)") var shouldRetry = false @@ -79,8 +76,8 @@ class ContentKeySessionDelegate : NSObject, AVContentKeySessionDelegate { // MARK: Logic func parsePlaybackId(fromSkdLocation uri: URL) -> String? { - // pull the playbackID out of the uri to the key - let urlComponents = URLComponents(url: uri, resolvingAgainstBaseURL: false) + // pull the playbackID out of the uri to the key + let urlComponents = URLComponents(url: uri, resolvingAgainstBaseURL: false) guard let urlComponents = urlComponents else { // not likely print("!! Error: Cannot Parse URI") @@ -95,7 +92,7 @@ class ContentKeySessionDelegate : NSObject, AVContentKeySessionDelegate { return playbackID } - func handleContentKeyRequest(_ session: AVContentKeySession, request: AVContentKeyRequest) { + func handleContentKeyRequest(request: any KeyRequest) { print("<><>handleContentKeyRequest: Called") // for hls, "the identifier must be an NSURL that matches a key URI in the Media Playlist." from the docs guard let keyURLStr = request.identifier as? String, @@ -108,39 +105,61 @@ class ContentKeySessionDelegate : NSObject, AVContentKeySessionDelegate { let playbackID = parsePlaybackId(fromSkdLocation: keyURL) guard let playbackID = playbackID else { - print("No playbackID found from server , aborting") + request.processContentKeyResponseError( + FairPlaySessionError.unexpected( + message: "playbackID not present in key uri" + ) + ) return } - let playbackOptions = PlayerSDK.shared.fairPlaySessionManager - .findRegisteredPlaybackOptions(for: playbackID) + guard let sessionManager = self.sessionManager else { + print("no session manager") + return + } + + let playbackOptions = sessionManager.findRegisteredPlaybackOptions( + for: playbackID + ) guard let playbackOptions = playbackOptions, case .drm(let drmOptions) = playbackOptions.playbackPolicy else { print("DRM Tokens must be registered when the AVPlayerItem is created, using FairplaySessionManager") + request.processContentKeyResponseError( + FairPlaySessionError.unexpected( + message: "Token was not registered, only happens during SDK errors" + ) + ) return } - let rootDomain = playbackOptions.customDomain ?? "mux.com" + 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() - PlayerSDK.shared.fairPlaySessionManager.requestCertificate( + sessionManager.requestCertificate( fromDomain: rootDomain, playbackID: playbackID, drmToken: drmOptions.drmToken, completion: { result in - if let cert = try? result.get() { - applicationCertificate = cert + do { + applicationCertificate = try result.get() + } catch { + appCertError = error } group.leave() } ) group.wait() guard let applicationCertificate = applicationCertificate else { - print("failed to get application certificate") + request.processContentKeyResponseError( + FairPlaySessionError.because( + cause: appCertError! + ) + ) return } @@ -154,9 +173,9 @@ class ContentKeySessionDelegate : NSObject, AVContentKeySessionDelegate { } guard let spcData = spcData else { - print("No SPC Data in spc response") - // `error` will be non-nil by contract - request.processContentKeyResponseError(error!) + request.processContentKeyResponseError( + error ?? FairPlaySessionError.unexpected(message: "no SPC") + ) return } @@ -171,18 +190,23 @@ class ContentKeySessionDelegate : NSObject, AVContentKeySessionDelegate { } } - private func handleSpcObtainedFromCDM( + func handleSpcObtainedFromCDM( spcData: Data, playbackID: String, drmToken: String, rootDomain: String, // without any "license." or "stream." prepended, eg mux.com, custom.1234.co.uk - request: AVContentKeyRequest + request: any KeyRequest ) { + guard let sessionManager = self.sessionManager else { + print("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() - PlayerSDK.shared.fairPlaySessionManager.requestLicense( + sessionManager.requestLicense( spcData: spcData, playbackID: playbackID, drmToken: drmToken, @@ -197,19 +221,80 @@ class ContentKeySessionDelegate : NSObject, AVContentKeySessionDelegate { group.wait() guard let ckcData = ckcData else { - print("no CKC Data in CKC response") - request.processContentKeyResponseError(TempError()) + request.processContentKeyResponseError(FairPlaySessionError.unexpected(message: "No CKC Data returned from CDM")) return } print("Submitting CKC to system") // Send CKC to CDM/wherever else so we can finally play our content - let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: ckcData) +// let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: ckcData) + let keyResponse = request.makeContentKeyResponse(data: ckcData) request.processContentKeyResponse(keyResponse) // 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 + } +} + extension URLComponents { func findQueryValue(key: String) -> String? { if let items = self.queryItems { diff --git a/Sources/MuxPlayerSwift/Fairplay/FairPlaySessionManager.swift b/Sources/MuxPlayerSwift/FairPlay/FairPlaySessionManager.swift similarity index 50% rename from Sources/MuxPlayerSwift/Fairplay/FairPlaySessionManager.swift rename to Sources/MuxPlayerSwift/FairPlay/FairPlaySessionManager.swift index e5a89d4c..b8424c25 100644 --- a/Sources/MuxPlayerSwift/Fairplay/FairPlaySessionManager.swift +++ b/Sources/MuxPlayerSwift/FairPlay/FairPlaySessionManager.swift @@ -8,24 +8,23 @@ import Foundation import AVFoundation -protocol FairPlaySessionManager { - - // MARK: Track DRM-protected assets - - func addContentKeyRecipient(_ recipient: AVContentKeyRecipient) - func removeContentKeyRecipient(_ recipient: AVContentKeyRecipient) - +// 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 + + // 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? + // 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, @@ -34,38 +33,126 @@ protocol FairPlaySessionManager { offline _: Bool, completion requestCompletion: @escaping (Result) -> Void ) - - // MARK: registering assets - +} + +// 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: helpers - - func makeLicenseDomain(_ rootDomain: String) -> String - func makeLicenseURL(playbackId: String, drmToken: String, licenseDomain: String) -> URL - func makeAppCertificateURL(playbackId: String, drmToken: String, licenseDomain: String) -> URL } -class FairPlaySessionManagerImpl: FairPlaySessionManager { - - private var playbackOptionsByPlaybackID: [String: PlaybackOptions] = [:] +// 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: helpers for interacting with the license server + +extension String { + // Generates a domain name appropriate for the Mux license proxy associted with the given + // "root domain". For example `mux.com` returns `license.mux.com` and + // `customdomain.xyz.com` returns `license.customdomain.xyz.com` + static func makeLicenseDomain(rootDomain: String) -> Self { + let customDomainWithDefault = rootDomain + let licenseDomain = "license.\(customDomainWithDefault)" + + // TODO: this check should not reach production or playing from staging will probably break + if("staging.mux.com" == customDomainWithDefault) { + return "license.gcp-us-west1-vos1.staging.mux.com" + } else { + return licenseDomain + } + } +} + +extension URL { + // Generates an authenticated URL to Mux's license proxy, for a 'license' (a CKC for fairplay), + // for the given playabckID and DRM Token, at the given domain + // - SeeAlso ``init(playbackID:,drmToken:,applicationCertificateLicenseDomain:)`` + init( + playbackID: String, + drmToken: String, + licenseDomain: String + ) { + let absoluteString = "https://\(licenseDomain)/license/fairplay/\(playbackID)?token=\(drmToken)" + self.init(string: absoluteString)! + } + + // Generates an authenticated URL to Mux's license proxy, for an application certificate, for the + // given plabackID and DRM token, at the given domain + // - SeeAlso ``init(playbackID:,drmToken:,licenseDomain: String)`` + init( + playbackID: String, + drmToken: String, + applicationCertificateLicenseDomain: String + ) { + let absoluteString = "https://\(applicationCertificateLicenseDomain)/appcert/fairplay/\(playbackID)?token=\(drmToken)" + self.init(string: absoluteString)! + } +} + +// MARK: - DefaultFairPlayStreamingSessionManager + +class DefaultFairPlayStreamingSessionManager< + ContentKeySession: ContentKeyProvider +>: FairPlayStreamingSessionManager { + + var playbackOptionsByPlaybackID: [String: PlaybackOptions] = [:] // note - null on simulators or other environments where fairplay isn't supported - private let contentKeySession: AVContentKeySession? - private let sessionDelegate: AVContentKeySessionDelegate? - - private let urlSession = URLSession.shared + let contentKeySession: ContentKeySession + + 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) + contentKeySession.addContentKeyRecipient(recipient) } func removeContentKeyRecipient(_ recipient: AVContentKeyRecipient) { - contentKeySession?.removeContentKeyRecipient(recipient) + contentKeySession.removeContentKeyRecipient(recipient) } // MARK: Requesting licenses and certs @@ -77,10 +164,12 @@ class FairPlaySessionManagerImpl: FairPlaySessionManager { drmToken: String, completion requestCompletion: @escaping (Result) -> Void ) { - let url = makeAppCertificateURL( - playbackId: playbackID, + let url = URL( + playbackID: playbackID, drmToken: drmToken, - licenseDomain: makeLicenseDomain(rootDomain) + applicationCertificateLicenseDomain: String.makeLicenseDomain( + rootDomain: rootDomain + ) ) var request = URLRequest(url: url) request.httpMethod = "GET" @@ -95,25 +184,37 @@ class FairPlaySessionManagerImpl: FairPlaySessionManager { print("Cert response headers: ", httpResponse.allHeaderFields) if let errorBody = data { let errorUtf = String(data: errorBody, encoding: .utf8) - print("Cert Error: \(errorUtf)") + print("Cert Error: \(errorUtf ?? "nil")") } - - } - // error case: I/O finished with non-successful response - guard responseCode == 200 else { - print("Cert request failed: \(responseCode)") - requestCompletion(Result.failure(TempError())) - return + } // error case: I/O failed if let error = error { print("Cert Request Failed: \(error.localizedDescription)") - requestCompletion(Result.failure(error)) // todo - real Error type + requestCompletion(Result.failure( + FairPlaySessionError.because(cause: error) + )) return } - guard let data = data else { + // error case: I/O finished with non-successful response + guard responseCode == 200 else { + print("Cert request failed: \(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 { print("Cert data unexpectedly nil from server") - requestCompletion(Result.failure(TempError())) // todo - real Error type + requestCompletion(Result.failure( + FairPlaySessionError.unexpected(message: "No cert data with 200 OK respone") + )) return } @@ -135,10 +236,12 @@ class FairPlaySessionManagerImpl: FairPlaySessionManager { offline _: Bool, completion requestCompletion: @escaping (Result) -> Void ) { - let url = makeLicenseURL( - playbackId: playbackID, + let url = URL( + playbackID: playbackID, drmToken: drmToken, - licenseDomain: makeLicenseDomain(rootDomain) + licenseDomain: String.makeLicenseDomain( + rootDomain: rootDomain + ) ) var request = URLRequest(url: url) @@ -149,42 +252,50 @@ class FairPlaySessionManagerImpl: FairPlaySessionManager { // QUERY PARAMS request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") request.setValue(String(format: "%lu", request.httpBody?.count ?? 0), forHTTPHeaderField: "Content-Length") - print("Sending License/CKC Request to: \(request.url?.absoluteString)") - print("\t with header fields: \(request.allHTTPHeaderFields)") + print("Sending License/CKC Request to: \(request.url?.absoluteString ?? "nil")") + print("\t with header fields: \(String(describing: request.allHTTPHeaderFields))") let task = urlSession.dataTask(with: request) { [requestCompletion] data, response, error in - print("<><> GOT LICENSE RESPONSE") + // error case: I/O failed + if let error = error { + print("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 print("License response code: \(httpResponse.statusCode)") print("License response headers: ", httpResponse.allHeaderFields) - } // error case: I/O finished with non-successful response guard responseCode == 200 else { - print("CKC request failed: \(responseCode)") - requestCompletion(Result.failure(TempError())) - return - } - // error case: I/O failed - if let error = error { - print("URL Session Task Failed: \(error.localizedDescription)") - requestCompletion(Result.failure(error)) // todo - real Error type + print("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 else { + guard let data = data, + data.count > 0 + else { print("No CKC data despite server returning success") - requestCompletion(Result.failure(TempError())) // todo - real Error type + requestCompletion(Result.failure( + FairPlaySessionError.unexpected(message: "No license data with 200 response") + )) return } let ckcData = data requestCompletion(Result.success(ckcData)) - print("") } task.resume() } @@ -192,13 +303,18 @@ class FairPlaySessionManagerImpl: FairPlaySessionManager { // MARK: registering assets /// Registers a ``PlaybackOptions`` for DRM playback, associated with the given playbackID - func registerPlaybackOptions(_ opts: PlaybackOptions, for playbackID: String) { + func registerPlaybackOptions( + _ options: PlaybackOptions, + for playbackID: String + ) { print("Registering playbackID \(playbackID)") - playbackOptionsByPlaybackID[playbackID] = opts + playbackOptionsByPlaybackID[playbackID] = options } /// Gets a DRM token previously registered via ``registerPlaybackOptions`` - func findRegisteredPlaybackOptions(for playbackID: String) -> PlaybackOptions? { + func findRegisteredPlaybackOptions( + for playbackID: String + ) -> PlaybackOptions? { print("Finding playbackID \(playbackID)") return playbackOptionsByPlaybackID[playbackID] } @@ -208,64 +324,22 @@ class FairPlaySessionManagerImpl: FairPlaySessionManager { print("UN-Registering playbackID \(playbackID)") playbackOptionsByPlaybackID.removeValue(forKey: playbackID) } - - // MARK: helpers - - func makeLicenseDomain(_ rootDomain: String) -> String { - let customDomainWithDefault = rootDomain ?? "mux.com" - let licenseDomain = "license.\(customDomainWithDefault)" - - // TODO: this check should not reach production or playing from staging will probably break - if("staging.mux.com" == customDomainWithDefault) { - return "license.gcp-us-west1-vos1.staging.mux.com" - } else { - return licenseDomain - } - } - - func makeLicenseURL(playbackId: String, drmToken: String, licenseDomain: String) -> URL { - let baseStr = "https://\(licenseDomain)/license/fairplay/\(playbackId)?token=\(drmToken)" - let url = URL(string: baseStr) - return url! - } - - func makeAppCertificateURL(playbackId: String, drmToken: String, licenseDomain: String) -> URL { - let baseStr = "https://\(licenseDomain)/appcert/fairplay/\(playbackId)?token=\(drmToken)" - let url = URL(string: baseStr) - return url! - } - + // MARK: initializers - - convenience init() { -#if targetEnvironment(simulator) - let session: AVContentKeySession? = nil - let delegate: AVContentKeySessionDelegate? = nil -#else - let session = AVContentKeySession(keySystem: .fairPlayStreaming) - let delegate = ContentKeySessionDelegate() -#endif - - self.init( - contentKeySession: session, - sessionDelegate: delegate, - sessionDelegateQueue: DispatchQueue(label: "com.mux.player.fairplay") - ) - } - + init( - contentKeySession: AVContentKeySession?, - sessionDelegate: AVContentKeySessionDelegate?, - sessionDelegateQueue: DispatchQueue + contentKeySession: ContentKeySession, + urlSession: URLSession ) { - contentKeySession?.setDelegate(sessionDelegate, queue: sessionDelegateQueue) - self.contentKeySession = contentKeySession - self.sessionDelegate = sessionDelegate + self.urlSession = urlSession } } +// MARK: - FairPlaySessionError -// TODO: Final implementation needs something more verbose -class TempError: Error { +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 3fecf929..9d4ccb58 100644 --- a/Sources/MuxPlayerSwift/GlobalLifecycle/PlayerSDK.swift +++ b/Sources/MuxPlayerSwift/GlobalLifecycle/PlayerSDK.swift @@ -18,12 +18,53 @@ class PlayerSDK { let keyValueObservation: KeyValueObservation - let fairPlaySessionManager: FairPlaySessionManager + let fairPlaySessionManager: FairPlayStreamingSessionManager - init() { + 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.keyValueObservation = KeyValueObservation() - self.fairPlaySessionManager = FairPlaySessionManagerImpl() + self.fairPlaySessionManager = fairPlayStreamingSessionManager + } + + 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 + ) + } } class KeyValueObservation { diff --git a/Sources/MuxPlayerSwift/InternalExtensions/AVPlayerItem+Mux.swift b/Sources/MuxPlayerSwift/InternalExtensions/AVPlayerItem+Mux.swift new file mode 100644 index 00000000..0373173c --- /dev/null +++ b/Sources/MuxPlayerSwift/InternalExtensions/AVPlayerItem+Mux.swift @@ -0,0 +1,151 @@ +// +// AVPlayerItem+Mux.swift +// + +import AVFoundation +import Foundation + +internal extension URL { + static func make( + playbackID: String, + playbackOptions: PlaybackOptions + ) -> Self { + 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 + + } else if case PlaybackOptions.PlaybackPolicy.drm(let drmPlaybackOptions) = playbackOptions.playbackPolicy { + + var queryItems: [URLQueryItem] = [] + + queryItems.append( + URLQueryItem( + name: "token", + value: drmPlaybackOptions.playbackToken + ) + ) + + components.queryItems = queryItems + + } + + 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 + // references your Mux Video at the supplied playback ID. + // The playback ID must be public. + // + // This initializer uses https://stream.mux.com as the + // base URL. Use a different initializer if using a custom + // playback URL. + // + // - Parameter playbackID: playback ID of the Mux Asset + // you'd like to play + convenience init(playbackID: String) { + self.init( + playbackID: playbackID, + playbackOptions: PlaybackOptions() + ) + } + + // Initializes a player item with a playback URL that + // references your Mux Video at the supplied playback ID. + // The playback ID must be public. + // + // - Parameters: + // - playbackID: playback ID of the Mux Asset + // you'd like to play + convenience init( + playbackID: String, + playbackOptions: PlaybackOptions + ) { + + // Create a new `AVAsset` that has been prepared + // for playback + let asset = AVURLAsset( + url: URL.make( + playbackID: playbackID, + playbackOptions: playbackOptions + ) + ) + + self.init( + asset: asset + ) + + PlayerSDK.shared.registerPlayerItem( + self, + playbackID: playbackID, + playbackOptions: playbackOptions + ) + } +} diff --git a/Sources/MuxPlayerSwift/PublicAPI/Extensions/AVPlayerItem+Mux.swift b/Sources/MuxPlayerSwift/PublicAPI/Extensions/AVPlayerItem+Mux.swift deleted file mode 100644 index 6c052c52..00000000 --- a/Sources/MuxPlayerSwift/PublicAPI/Extensions/AVPlayerItem+Mux.swift +++ /dev/null @@ -1,153 +0,0 @@ -// -// AVPlayerItem+Mux.swift -// - -import AVFoundation -import Foundation - -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 - - } else if case PlaybackOptions.PlaybackPolicy.drm(let drmPlaybackOptions) = playbackOptions.playbackPolicy { - - var queryItems: [URLQueryItem] = [] - - queryItems.append( - URLQueryItem( - name: "token", - value: drmPlaybackOptions.playbackToken - ) - ) - - components.queryItems = queryItems - - } - - guard let playbackURL = components.url else { - preconditionFailure("Invalid playback URL components") - } - - return playbackURL -} - -/// Create a new `AVAsset` that has been prepared for playback -/// If DRM is required, the Asset will be registered with the ``FairPlaySessionManager`` -fileprivate func makeAVAsset(playbackID: String, playbackOptions: PlaybackOptions) -> AVAsset { - let url = makePlaybackURL(playbackID: playbackID, playbackOptions: playbackOptions) - - let asset = AVURLAsset(url: url) - if case .drm(_) = playbackOptions.playbackPolicy { - PlayerSDK.shared.fairPlaySessionManager.registerPlaybackOptions(playbackOptions, for: playbackID) - // asset must be attached as early as possible to avoid crashes when attaching later - PlayerSDK.shared.fairPlaySessionManager.addContentKeyRecipient(asset) - } - - return asset -} - -internal extension AVPlayerItem { - - /// Initializes a player item with a playback URL that - /// references your Mux Video at the supplied playback ID. - /// The playback ID must be public. - /// - /// This initializer uses https://stream.mux.com as the - /// base URL. Use a different initializer if using a custom - /// playback URL. - /// - /// - Parameter playbackID: playback ID of the Mux Asset - /// you'd like to play - convenience init(playbackID: String) { - let playbackURL = makePlaybackURL( - playbackID: playbackID, - playbackOptions: PlaybackOptions() - ) - - self.init(url: playbackURL) - } - - /// Initializes a player item with a playback URL that - /// references your Mux Video at the supplied playback ID. - /// The playback ID must be public. - /// - /// - Parameters: - /// - playbackID: playback ID of the Mux Asset - /// you'd like to play - convenience init( - playbackID: String, - playbackOptions: PlaybackOptions - ) { - let asset = makeAVAsset( - playbackID: playbackID, - playbackOptions: playbackOptions - ) - - self.init(asset: asset) - } -} diff --git a/Sources/MuxPlayerSwift/PublicAPI/Options/PlaybackOptions.swift b/Sources/MuxPlayerSwift/PublicAPI/Options/PlaybackOptions.swift index 83b0e13a..a25a8f55 100644 --- a/Sources/MuxPlayerSwift/PublicAPI/Options/PlaybackOptions.swift +++ b/Sources/MuxPlayerSwift/PublicAPI/Options/PlaybackOptions.swift @@ -144,6 +144,8 @@ public struct PlaybackOptions { extension PlaybackOptions { + // MARK: public initializers + /// Initializes playback options for a public /// playback ID /// - Parameters: @@ -258,4 +260,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/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..5ca56894 --- /dev/null +++ b/Tests/MuxPlayerSwift/FairPlay/FairPlaySessionManagerTests.swift @@ -0,0 +1,640 @@ +// +// FairPlaySessionManagerTests.swift +// +// +// Created by Emily Dixon on 5/2/24. +// + +import Foundation +import XCTest +import AVKit +@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 + ) + + } + + // Also tests PlaybackOptions.rootDomain + func testMakeLicenseDomain() throws { + let optionsWithoutCustomDomain = PlaybackOptions() + let defaultLicenseDomain = String.makeLicenseDomain( + rootDomain: optionsWithoutCustomDomain.rootDomain() + ) + XCTAssert( + defaultLicenseDomain == "license.mux.com", + "Default license server is license.mux.com" + ) + + var optionsCustomDomain = PlaybackOptions() + optionsCustomDomain.customDomain = "fake.custom.domain.xyz" + let customLicenseDomain = String.makeLicenseDomain( + rootDomain: optionsCustomDomain.rootDomain() + ) + XCTAssert( + customLicenseDomain == "license.fake.custom.domain.xyz", + "Custom license server is license.fake.custom.domain.xyz" + ) + } + + func testMakeLicenseURL() throws { + let fakePlaybackId = "fake_playback_id" + let fakeDrmToken = "fake_drm_token" + let fakeLicenseDomain = "license.fake.domain.xyz" + + let licenseURL = URL( + playbackID: fakePlaybackId, + drmToken: fakeDrmToken, + licenseDomain: fakeLicenseDomain + ) + let expected = "https://\(fakeLicenseDomain)/license/fairplay/\(fakePlaybackId)?token=\(fakeDrmToken)" + + XCTAssertEqual( + expected, licenseURL.absoluteString + ) + } + + func testMakeAppCertificateUrl() throws { + let fakePlaybackId = "fake_playback_id" + let fakeDrmToken = "fake_drm_token" + let fakeLicenseDomain = "license.fake.domain.xyz" + + let licenseURL = URL( + playbackID: fakePlaybackId, + drmToken: fakeDrmToken, + applicationCertificateLicenseDomain: fakeLicenseDomain + ) + let expected = "https://\(fakeLicenseDomain)/appcert/fairplay/\(fakePlaybackId)?token=\(fakeDrmToken)" + + XCTAssertEqual( + expected, 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..91512c32 --- /dev/null +++ b/Tests/MuxPlayerSwift/Helpers/TestFairPlayStreamingSessionCredentialClient.swift @@ -0,0 +1,53 @@ +// +// TestFairPlayStreamingCredentialsClient.swift +// +// +// Created by Emily Dixon on 5/7/24. +// + +import Foundation +@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)! + + 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..b7a15c9c --- /dev/null +++ b/Tests/MuxPlayerSwift/Helpers/TestFairPlayStreamingSessionManager.swift @@ -0,0 +1,42 @@ +// +// TestFairPlayStreamingManager.swift +// +// +// Created by Emily Dixon on 5/7/24. +// + +import Foundation +import AVFoundation +@testable import MuxPlayerSwift + +class TestFairPlayStreamingSessionManager : FairPlayStreamingSessionCredentialClient & PlaybackOptionsRegistry { + + let credentialClient: FairPlayStreamingSessionCredentialClient + let optionsRegistry: PlaybackOptionsRegistry + + 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 + } + + +}