diff --git a/docs/pages/component/drm.mdx b/docs/pages/component/drm.mdx index 6e7fa0aaf3..da9074d5ea 100644 --- a/docs/pages/component/drm.mdx +++ b/docs/pages/component/drm.mdx @@ -137,6 +137,20 @@ You can specify the DRM type, either by string or using the exported DRMType enu Valid values are, for Android: DRMType.WIDEVINE / DRMType.PLAYREADY / DRMType.CLEARKEY. for iOS: DRMType.FAIRPLAY +### `localSourceEncryptionKeyScheme` + + + +Set the url scheme for stream encryption key for local assets + +Type: String + +Example: + +``` +localSourceEncryptionKeyScheme="my-offline-key" +``` + ## Common Usage Scenarios ### Send cookies to license server diff --git a/docs/pages/component/props.mdx b/docs/pages/component/props.mdx index 2129886814..f976dfbc99 100644 --- a/docs/pages/component/props.mdx +++ b/docs/pages/component/props.mdx @@ -339,19 +339,6 @@ Controls the iOS silent switch behavior - **"ignore"** - Play audio even if the silent switch is set - **"obey"** - Don't play audio if the silent switch is set -### `localSourceEncryptionKeyScheme` - - - -Set the url scheme for stream encryption key for local assets - -Type: String - -Example: - -``` -localSourceEncryptionKeyScheme="my-offline-key" -``` ### `maxBitRate` @@ -789,7 +776,7 @@ The following other types are supported on some platforms, but aren't fully docu #### Using DRM content - + To setup DRM please follow [this guide](/component/drm) @@ -807,8 +794,6 @@ Example: }, ``` -> ⚠️ DRM is not supported on visionOS yet - #### Start playback at a specific point in time diff --git a/ios/Video/DataStructures/DRMParams.swift b/ios/Video/DataStructures/DRMParams.swift index ce91d4dc34..bf8a4d2a49 100644 --- a/ios/Video/DataStructures/DRMParams.swift +++ b/ios/Video/DataStructures/DRMParams.swift @@ -5,6 +5,7 @@ struct DRMParams { let contentId: String? let certificateUrl: String? let base64Certificate: Bool? + let localSourceEncryptionKeyScheme: String? let json: NSDictionary? @@ -17,6 +18,7 @@ struct DRMParams { self.certificateUrl = nil self.base64Certificate = nil self.headers = nil + self.localSourceEncryptionKeyScheme = nil return } self.json = json @@ -36,5 +38,6 @@ struct DRMParams { } else { self.headers = nil } + localSourceEncryptionKeyScheme = json["localSourceEncryptionKeyScheme"] as? String } } diff --git a/ios/Video/DataStructures/VideoSource.swift b/ios/Video/DataStructures/VideoSource.swift index e672929fe7..45e5b2d300 100644 --- a/ios/Video/DataStructures/VideoSource.swift +++ b/ios/Video/DataStructures/VideoSource.swift @@ -10,7 +10,7 @@ struct VideoSource { let cropEnd: Int64? let customMetadata: CustomMetadata? /* DRM */ - let drm: DRMParams? + let drm: DRMParams var textTracks: [TextTrack] = [] let json: NSDictionary? @@ -28,7 +28,7 @@ struct VideoSource { self.cropStart = nil self.cropEnd = nil self.customMetadata = nil - self.drm = nil + self.drm = DRMParams(nil) return } self.json = json diff --git a/ios/Video/Features/DRMManager+AVContentKeySessionDelegate.swift b/ios/Video/Features/DRMManager+AVContentKeySessionDelegate.swift new file mode 100644 index 0000000000..f8d31fd017 --- /dev/null +++ b/ios/Video/Features/DRMManager+AVContentKeySessionDelegate.swift @@ -0,0 +1,41 @@ +// +// DRMManager+AVContentKeySessionDelegate.swift +// react-native-video +// +// Created by Krzysztof Moch on 14/08/2024. +// + +import AVFoundation + +extension DRMManager: AVContentKeySessionDelegate { + func contentKeySession(_: AVContentKeySession, didProvide keyRequest: AVContentKeyRequest) { + handleContentKeyRequest(keyRequest: keyRequest) + } + + func contentKeySession(_: AVContentKeySession, didProvideRenewingContentKeyRequest keyRequest: AVContentKeyRequest) { + handleContentKeyRequest(keyRequest: keyRequest) + } + + func contentKeySession(_: AVContentKeySession, shouldRetry _: AVContentKeyRequest, reason retryReason: AVContentKeyRequest.RetryReason) -> Bool { + let retryReasons: [AVContentKeyRequest.RetryReason] = [ + .timedOut, + .receivedResponseWithExpiredLease, + .receivedObsoleteContentKey, + ] + return retryReasons.contains(retryReason) + } + + func contentKeySession(_: AVContentKeySession, didProvide keyRequest: AVPersistableContentKeyRequest) { + Task { + do { + try await handlePersistableKeyRequest(keyRequest: keyRequest) + } catch { + handleError(error, for: keyRequest) + } + } + } + + func contentKeySession(_: AVContentKeySession, contentKeyRequest _: AVContentKeyRequest, didFailWithError error: Error) { + DebugLog(String(describing: error)) + } +} diff --git a/ios/Video/Features/DRMManager+OnGetLicense.swift b/ios/Video/Features/DRMManager+OnGetLicense.swift new file mode 100644 index 0000000000..90b1c38cb1 --- /dev/null +++ b/ios/Video/Features/DRMManager+OnGetLicense.swift @@ -0,0 +1,68 @@ +// +// DRMManager+OnGetLicense.swift +// react-native-video +// +// Created by Krzysztof Moch on 14/08/2024. +// + +import AVFoundation + +extension DRMManager { + func requestLicenseFromJS(spcData: Data, assetId: String, keyRequest: AVContentKeyRequest) async throws { + guard let onGetLicense else { + throw RCTVideoError.noDataFromLicenseRequest + } + + guard let licenseServerUrl = drmParams?.licenseServer, !licenseServerUrl.isEmpty else { + throw RCTVideoError.noLicenseServerURL + } + + guard let loadedLicenseUrl = keyRequest.identifier as? String else { + throw RCTVideoError.invalidContentId + } + + pendingLicenses[loadedLicenseUrl] = keyRequest + + DispatchQueue.main.async { [weak self] in + onGetLicense([ + "licenseUrl": licenseServerUrl, + "loadedLicenseUrl": loadedLicenseUrl, + "contentId": assetId, + "spcBase64": spcData.base64EncodedString(), + "target": self?.reactTag as Any, + ]) + } + } + + func setJSLicenseResult(license: String, licenseUrl: String) { + guard let keyContentRequest = pendingLicenses[licenseUrl] else { + setJSLicenseError(error: "Loading request for licenseUrl \(licenseUrl) not found", licenseUrl: licenseUrl) + return + } + + guard let responseData = Data(base64Encoded: license) else { + setJSLicenseError(error: "Invalid license data", licenseUrl: licenseUrl) + return + } + + do { + try finishProcessingContentKeyRequest(keyRequest: keyContentRequest, license: responseData) + pendingLicenses.removeValue(forKey: licenseUrl) + } catch { + handleError(error, for: keyContentRequest) + } + } + + func setJSLicenseError(error: String, licenseUrl: String) { + let rctError = RCTVideoError.fromJSPart(error) + + DispatchQueue.main.async { [weak self] in + self?.onVideoError?([ + "error": RCTVideoErrorHandler.createError(from: rctError), + "target": self?.reactTag as Any, + ]) + } + + pendingLicenses.removeValue(forKey: licenseUrl) + } +} diff --git a/ios/Video/Features/DRMManager+Persitable.swift b/ios/Video/Features/DRMManager+Persitable.swift new file mode 100644 index 0000000000..022743d8a1 --- /dev/null +++ b/ios/Video/Features/DRMManager+Persitable.swift @@ -0,0 +1,34 @@ +// +// DRMManager+Persitable.swift +// react-native-video +// +// Created by Krzysztof Moch on 19/08/2024. +// + +import AVFoundation + +extension DRMManager { + func handlePersistableKeyRequest(keyRequest: AVPersistableContentKeyRequest) async throws { + if let localSourceEncryptionKeyScheme = drmParams?.localSourceEncryptionKeyScheme { + try handleEmbeddedKey(keyRequest: keyRequest, scheme: localSourceEncryptionKeyScheme) + } else { + // Offline DRM is not supported yet - if you need it please check out the following issue: + // https://github.com/TheWidlarzGroup/react-native-video/issues/3539 + throw RCTVideoError.offlineDRMNotSupported + } + } + + private func handleEmbeddedKey(keyRequest: AVPersistableContentKeyRequest, scheme: String) throws { + guard let uri = keyRequest.identifier as? String, + let url = URL(string: uri) else { + throw RCTVideoError.invalidContentId + } + + guard let persistentKeyData = RCTVideoUtils.extractDataFromCustomSchemeUrl(from: url, scheme: scheme) else { + throw RCTVideoError.embeddedKeyExtractionFailed + } + + let persistentKey = try keyRequest.persistableContentKey(fromKeyVendorResponse: persistentKeyData) + try finishProcessingContentKeyRequest(keyRequest: keyRequest, license: persistentKey) + } +} diff --git a/ios/Video/Features/DRMManager.swift b/ios/Video/Features/DRMManager.swift new file mode 100644 index 0000000000..d62df8b364 --- /dev/null +++ b/ios/Video/Features/DRMManager.swift @@ -0,0 +1,213 @@ +// +// DRMManager.swift +// react-native-video +// +// Created by Krzysztof Moch on 13/08/2024. +// + +import AVFoundation + +class DRMManager: NSObject { + static let queue = DispatchQueue(label: "RNVideoContentKeyDelegateQueue") + let contentKeySession: AVContentKeySession? + + var drmParams: DRMParams? + var reactTag: NSNumber? + var onVideoError: RCTDirectEventBlock? + var onGetLicense: RCTDirectEventBlock? + + // Licenses handled by onGetLicense (from JS side) + var pendingLicenses: [String: AVContentKeyRequest] = [:] + + override init() { + #if targetEnvironment(simulator) + contentKeySession = nil + super.init() + #else + contentKeySession = AVContentKeySession(keySystem: .fairPlayStreaming) + super.init() + + contentKeySession?.setDelegate(self, queue: DRMManager.queue) + #endif + } + + func createContentKeyRequest( + asset: AVContentKeyRecipient, + drmParams: DRMParams?, + reactTag: NSNumber?, + onVideoError: RCTDirectEventBlock?, + onGetLicense: RCTDirectEventBlock? + ) { + self.reactTag = reactTag + self.onVideoError = onVideoError + self.onGetLicense = onGetLicense + self.drmParams = drmParams + + if drmParams?.type != "fairplay" { + self.onVideoError?([ + "error": RCTVideoErrorHandler.createError(from: RCTVideoError.unsupportedDRMType), + "target": self.reactTag as Any, + ]) + return + } + + #if targetEnvironment(simulator) + DebugLog("Simulator is not supported for FairPlay DRM.") + self.onVideoError?([ + "error": RCTVideoErrorHandler.createError(from: RCTVideoError.simulatorDRMNotSupported), + "target": self.reactTag as Any, + ]) + #endif + + contentKeySession?.addContentKeyRecipient(asset) + } + + // MARK: - Internal + + func handleContentKeyRequest(keyRequest: AVContentKeyRequest) { + Task { + do { + if drmParams?.localSourceEncryptionKeyScheme != nil { + #if os(iOS) + try keyRequest.respondByRequestingPersistableContentKeyRequestAndReturnError() + return + #else + throw RCTVideoError.offlineDRMNotSuported + #endif + } + + try await processContentKeyRequest(keyRequest: keyRequest) + } catch { + handleError(error, for: keyRequest) + } + } + } + + func finishProcessingContentKeyRequest(keyRequest: AVContentKeyRequest, license: Data) throws { + let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: license) + keyRequest.processContentKeyResponse(keyResponse) + } + + func handleError(_ error: Error, for keyRequest: AVContentKeyRequest) { + let rctError: RCTVideoError + if let videoError = error as? RCTVideoError { + // handle RCTVideoError errors + rctError = videoError + + DispatchQueue.main.async { [weak self] in + self?.onVideoError?([ + "error": RCTVideoErrorHandler.createError(from: rctError), + "target": self?.reactTag as Any, + ]) + } + } else { + let err = error as NSError + + // handle Other errors + DispatchQueue.main.async { [weak self] in + self?.onVideoError?([ + "error": [ + "code": err.code, + "localizedDescription": err.localizedDescription, + "localizedFailureReason": err.localizedFailureReason ?? "", + "localizedRecoverySuggestion": err.localizedRecoverySuggestion ?? "", + "domain": err.domain, + ], + "target": self?.reactTag as Any, + ]) + } + } + + keyRequest.processContentKeyResponseError(error) + contentKeySession?.expire() + } + + // MARK: - Private + + private func processContentKeyRequest(keyRequest: AVContentKeyRequest) async throws { + guard let assetId = getAssetId(keyRequest: keyRequest), + let assetIdData = assetId.data(using: .utf8) else { + throw RCTVideoError.invalidContentId + } + + let appCertificate = try await requestApplicationCertificate() + let spcData = try await keyRequest.makeStreamingContentKeyRequestData(forApp: appCertificate, contentIdentifier: assetIdData) + + if onGetLicense != nil { + try await requestLicenseFromJS(spcData: spcData, assetId: assetId, keyRequest: keyRequest) + } else { + let license = try await requestLicense(spcData: spcData) + try finishProcessingContentKeyRequest(keyRequest: keyRequest, license: license) + } + } + + private func requestApplicationCertificate() async throws -> Data { + guard let urlString = drmParams?.certificateUrl, + let url = URL(string: urlString) else { + throw RCTVideoError.noCertificateURL + } + + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw RCTVideoError.noCertificateData + } + + if drmParams?.base64Certificate == true { + guard let certData = Data(base64Encoded: data) else { + throw RCTVideoError.noCertificateData + } + return certData + } + + return data + } + + private func requestLicense(spcData: Data) async throws -> Data { + guard let licenseServerUrlString = drmParams?.licenseServer, + let licenseServerUrl = URL(string: licenseServerUrlString) else { + throw RCTVideoError.noLicenseServerURL + } + + var request = URLRequest(url: licenseServerUrl) + request.httpMethod = "POST" + request.httpBody = spcData + + if let headers = drmParams?.headers { + for (key, value) in headers { + if let stringValue = value as? String { + request.setValue(stringValue, forHTTPHeaderField: key) + } + } + } + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw RCTVideoError.licenseRequestFailed(0) + } + + guard httpResponse.statusCode == 200 else { + throw RCTVideoError.licenseRequestFailed(httpResponse.statusCode) + } + + guard !data.isEmpty else { + throw RCTVideoError.noDataFromLicenseRequest + } + + return data + } + + private func getAssetId(keyRequest: AVContentKeyRequest) -> String? { + if let assetId = drmParams?.contentId { + return assetId + } + + if let url = keyRequest.identifier as? String { + return url.replacingOccurrences(of: "skd://", with: "") + } + + return nil + } +} diff --git a/ios/Video/Features/RCTResourceLoaderDelegate.swift b/ios/Video/Features/RCTResourceLoaderDelegate.swift deleted file mode 100644 index f7ab10314a..0000000000 --- a/ios/Video/Features/RCTResourceLoaderDelegate.swift +++ /dev/null @@ -1,186 +0,0 @@ -import AVFoundation - -class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDelegate { - private var _loadingRequests: [String: AVAssetResourceLoadingRequest?] = [:] - private var _requestingCertificate = false - private var _requestingCertificateErrored = false - private var _drm: DRMParams? - private var _localSourceEncryptionKeyScheme: String? - private var _reactTag: NSNumber? - private var _onVideoError: RCTDirectEventBlock? - private var _onGetLicense: RCTDirectEventBlock? - - init( - asset: AVURLAsset, - drm: DRMParams?, - localSourceEncryptionKeyScheme: String?, - onVideoError: RCTDirectEventBlock?, - onGetLicense: RCTDirectEventBlock?, - reactTag: NSNumber - ) { - super.init() - let queue = DispatchQueue(label: "assetQueue") - asset.resourceLoader.setDelegate(self, queue: queue) - _reactTag = reactTag - _onVideoError = onVideoError - _onGetLicense = onGetLicense - _drm = drm - _localSourceEncryptionKeyScheme = localSourceEncryptionKeyScheme - } - - deinit { - for request in _loadingRequests.values { - request?.finishLoading() - } - } - - func resourceLoader(_: AVAssetResourceLoader, shouldWaitForRenewalOfRequestedResource renewalRequest: AVAssetResourceRenewalRequest) -> Bool { - return loadingRequestHandling(renewalRequest) - } - - func resourceLoader(_: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { - return loadingRequestHandling(loadingRequest) - } - - func resourceLoader(_: AVAssetResourceLoader, didCancel _: AVAssetResourceLoadingRequest) { - RCTLog("didCancelLoadingRequest") - } - - func setLicenseResult(_ license: String!, _ licenseUrl: String!) { - // Check if the loading request exists in _loadingRequests based on licenseUrl - guard let loadingRequest = _loadingRequests[licenseUrl] else { - setLicenseResultError("Loading request for licenseUrl \(String(describing: licenseUrl)) not found", licenseUrl) - return - } - - // Check if the license data is valid - guard let respondData = RCTVideoUtils.base64DataFromBase64String(base64String: license) else { - setLicenseResultError("No data from JS license response", licenseUrl) - return - } - - let dataRequest: AVAssetResourceLoadingDataRequest! = loadingRequest?.dataRequest - dataRequest.respond(with: respondData) - loadingRequest!.finishLoading() - _loadingRequests.removeValue(forKey: licenseUrl) - } - - func setLicenseResultError(_ error: String!, _ licenseUrl: String!) { - // Check if the loading request exists in _loadingRequests based on licenseUrl - guard let loadingRequest = _loadingRequests[licenseUrl] else { - print("Loading request for licenseUrl \(licenseUrl) not found. Error: \(error)") - return - } - - self.finishLoadingWithError(error: RCTVideoErrorHandler.fromJSPart(error), licenseUrl: licenseUrl) - } - - func finishLoadingWithError(error: Error!, licenseUrl: String!) -> Bool { - // Check if the loading request exists in _loadingRequests based on licenseUrl - guard let loadingRequest = _loadingRequests[licenseUrl], let error = error as NSError? else { - // Handle the case where the loading request is not found or error is nil - return false - } - - loadingRequest!.finishLoading(with: error) - _loadingRequests.removeValue(forKey: licenseUrl) - _onVideoError?([ - "error": [ - "code": NSNumber(value: error.code), - "localizedDescription": error.localizedDescription, - "localizedFailureReason": error.localizedFailureReason ?? "", - "localizedRecoverySuggestion": error.localizedRecoverySuggestion ?? "", - "domain": error.domain, - ], - "target": _reactTag as Any, - ]) - - return false - } - - func loadingRequestHandling(_ loadingRequest: AVAssetResourceLoadingRequest!) -> Bool { - if handleEmbeddedKey(loadingRequest) { - return true - } - - if _drm != nil { - return handleDrm(loadingRequest) - } - - return false - } - - func handleEmbeddedKey(_ loadingRequest: AVAssetResourceLoadingRequest!) -> Bool { - guard let url = loadingRequest.request.url, - let _localSourceEncryptionKeyScheme, - let persistentKeyData = RCTVideoUtils.extractDataFromCustomSchemeUrl(from: url, scheme: _localSourceEncryptionKeyScheme) - else { - return false - } - - loadingRequest.contentInformationRequest?.contentType = AVStreamingKeyDeliveryPersistentContentKeyType - loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = true - loadingRequest.contentInformationRequest?.contentLength = Int64(persistentKeyData.count) - loadingRequest.dataRequest?.respond(with: persistentKeyData) - loadingRequest.finishLoading() - - return true - } - - func handleDrm(_ loadingRequest: AVAssetResourceLoadingRequest!) -> Bool { - if _requestingCertificate { - return true - } else if _requestingCertificateErrored { - return false - } - - let requestKey: String = loadingRequest.request.url?.absoluteString ?? "" - - _loadingRequests[requestKey] = loadingRequest - - guard let _drm, let drmType = _drm.type, drmType == "fairplay" else { - return finishLoadingWithError(error: RCTVideoErrorHandler.noDRMData, licenseUrl: requestKey) - } - - Task { - do { - if _onGetLicense != nil { - let contentId = _drm.contentId ?? loadingRequest.request.url?.host - let spcData = try await RCTVideoDRM.handleWithOnGetLicense( - loadingRequest: loadingRequest, - contentId: contentId, - certificateUrl: _drm.certificateUrl, - base64Certificate: _drm.base64Certificate - ) - - self._requestingCertificate = true - self._onGetLicense?(["licenseUrl": self._drm?.licenseServer ?? "", - "loadedLicenseUrl": loadingRequest.request.url?.absoluteString ?? "", - "contentId": contentId ?? "", - "spcBase64": spcData.base64EncodedString(options: []), - "target": self._reactTag as Any]) - } else { - let data = try await RCTVideoDRM.handleInternalGetLicense( - loadingRequest: loadingRequest, - contentId: _drm.contentId, - licenseServer: _drm.licenseServer, - certificateUrl: _drm.certificateUrl, - base64Certificate: _drm.base64Certificate, - headers: _drm.headers - ) - - guard let dataRequest = loadingRequest.dataRequest else { - throw RCTVideoErrorHandler.noCertificateData - } - dataRequest.respond(with: data) - loadingRequest.finishLoading() - } - } catch { - self.finishLoadingWithError(error: error, licenseUrl: requestKey) - self._requestingCertificateErrored = true - } - } - - return true - } -} diff --git a/ios/Video/Features/RCTVideoDRM.swift b/ios/Video/Features/RCTVideoDRM.swift deleted file mode 100644 index bc73d48df3..0000000000 --- a/ios/Video/Features/RCTVideoDRM.swift +++ /dev/null @@ -1,161 +0,0 @@ -import AVFoundation - -enum RCTVideoDRM { - static func fetchLicense( - licenseServer: String, - spcData: Data?, - contentId: String, - headers: [String: Any]? - ) async throws -> Data { - let request = createLicenseRequest(licenseServer: licenseServer, spcData: spcData, contentId: contentId, headers: headers) - - let (data, response) = try await URLSession.shared.data(from: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw RCTVideoErrorHandler.noDataFromLicenseRequest - } - - if httpResponse.statusCode != 200 { - print("Error getting license from \(licenseServer), HTTP status code \(httpResponse.statusCode)") - throw RCTVideoErrorHandler.licenseRequestNotOk(httpResponse.statusCode) - } - - guard let decodedData = Data(base64Encoded: data, options: []) else { - throw RCTVideoErrorHandler.noDataFromLicenseRequest - } - - return decodedData - } - - static func createLicenseRequest( - licenseServer: String, - spcData: Data?, - contentId: String, - headers: [String: Any]? - ) -> URLRequest { - var request = URLRequest(url: URL(string: licenseServer)!) - request.httpMethod = "POST" - - if let headers { - for item in headers { - guard let key = item.key as? String, let value = item.value as? String else { - continue - } - request.setValue(value, forHTTPHeaderField: key) - } - } - - let spcEncoded = spcData?.base64EncodedString(options: []) - let spcUrlEncoded = CFURLCreateStringByAddingPercentEscapes( - kCFAllocatorDefault, - spcEncoded as? CFString? as! CFString, - nil, - "?=&+" as CFString, - CFStringBuiltInEncodings.UTF8.rawValue - ) as? String - let post = String(format: "spc=%@&%@", spcUrlEncoded as! CVarArg, contentId) - let postData = post.data(using: String.Encoding.utf8, allowLossyConversion: true) - request.httpBody = postData - - return request - } - - static func fetchSpcData( - loadingRequest: AVAssetResourceLoadingRequest, - certificateData: Data, - contentIdData: Data - ) throws -> Data { - #if os(visionOS) - // TODO: DRM is not supported yet on visionOS. See #3467 - throw NSError(domain: "DRM is not supported yet on visionOS", code: 0, userInfo: nil) - #else - guard let spcData = try? loadingRequest.streamingContentKeyRequestData( - forApp: certificateData, - contentIdentifier: contentIdData as Data, - options: nil - ) else { - throw RCTVideoErrorHandler.noSPC - } - - return spcData - #endif - } - - static func createCertificateData(certificateStringUrl: String?, base64Certificate: Bool?) throws -> Data { - guard let certificateStringUrl, - let certificateURL = URL(string: certificateStringUrl.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) ?? "") else { - throw RCTVideoErrorHandler.noCertificateURL - } - - var certificateData: Data? - do { - certificateData = try Data(contentsOf: certificateURL) - if base64Certificate != nil { - certificateData = Data(base64Encoded: certificateData! as Data, options: .ignoreUnknownCharacters) - } - } catch {} - - guard let certificateData else { - throw RCTVideoErrorHandler.noCertificateData - } - - return certificateData - } - - static func handleWithOnGetLicense(loadingRequest: AVAssetResourceLoadingRequest, contentId: String?, certificateUrl: String?, - base64Certificate: Bool?) throws -> Data { - let contentIdData = contentId?.data(using: .utf8) - - let certificateData = try? RCTVideoDRM.createCertificateData(certificateStringUrl: certificateUrl, base64Certificate: base64Certificate) - - guard let contentIdData else { - throw RCTVideoError.invalidContentId as! Error - } - - guard let certificateData else { - throw RCTVideoError.noCertificateData as! Error - } - - return try RCTVideoDRM.fetchSpcData( - loadingRequest: loadingRequest, - certificateData: certificateData, - contentIdData: contentIdData - ) - } - - static func handleInternalGetLicense( - loadingRequest: AVAssetResourceLoadingRequest, - contentId: String?, - licenseServer: String?, - certificateUrl: String?, - base64Certificate: Bool?, - headers: [String: Any]? - ) async throws -> Data { - let url = loadingRequest.request.url - - let parsedContentId = contentId != nil && !contentId!.isEmpty ? contentId : nil - - guard let contentId = parsedContentId ?? url?.absoluteString.replacingOccurrences(of: "skd://", with: "") else { - throw RCTVideoError.invalidContentId as! Error - } - - let contentIdData = NSData(bytes: contentId.cString(using: String.Encoding.utf8), length: contentId.lengthOfBytes(using: String.Encoding.utf8)) as Data - let certificateData = try RCTVideoDRM.createCertificateData(certificateStringUrl: certificateUrl, base64Certificate: base64Certificate) - let spcData = try RCTVideoDRM.fetchSpcData( - loadingRequest: loadingRequest, - certificateData: certificateData, - contentIdData: contentIdData - ) - - guard let licenseServer else { - throw RCTVideoError.noLicenseServerURL as! Error - } - - return try await RCTVideoDRM.fetchLicense( - licenseServer: licenseServer, - spcData: spcData, - contentId: contentId, - headers: headers - ) - } -} diff --git a/ios/Video/Features/RCTVideoErrorHandling.swift b/ios/Video/Features/RCTVideoErrorHandling.swift index 7dc687839e..b06031cfee 100644 --- a/ios/Video/Features/RCTVideoErrorHandling.swift +++ b/ios/Video/Features/RCTVideoErrorHandling.swift @@ -1,114 +1,188 @@ +import Foundation + // MARK: - RCTVideoError -enum RCTVideoError: Int { - case fromJSPart +enum RCTVideoError: Error, Hashable { + case fromJSPart(String) case noLicenseServerURL - case licenseRequestNotOk + case licenseRequestFailed(Int) case noDataFromLicenseRequest case noSPC - case noDataRequest case noCertificateData case noCertificateURL - case noFairplayDRM case noDRMData case invalidContentId -} - -// MARK: - RCTVideoErrorHandler - -enum RCTVideoErrorHandler { - static let noDRMData = NSError( - domain: "RCTVideo", - code: RCTVideoError.noDRMData.rawValue, - userInfo: [ - NSLocalizedDescriptionKey: "Error obtaining DRM license.", - NSLocalizedFailureReasonErrorKey: "No drm object found.", - NSLocalizedRecoverySuggestionErrorKey: "Have you specified the 'drm' prop?", - ] - ) - - static let noCertificateURL = NSError( - domain: "RCTVideo", - code: RCTVideoError.noCertificateURL.rawValue, - userInfo: [ - NSLocalizedDescriptionKey: "Error obtaining DRM License.", - NSLocalizedFailureReasonErrorKey: "No certificate URL has been found.", - NSLocalizedRecoverySuggestionErrorKey: "Did you specified the prop certificateUrl?", - ] - ) - - static let noCertificateData = NSError( - domain: "RCTVideo", - code: RCTVideoError.noCertificateData.rawValue, - userInfo: [ - NSLocalizedDescriptionKey: "Error obtaining DRM license.", - NSLocalizedFailureReasonErrorKey: "No certificate data obtained from the specificied url.", - NSLocalizedRecoverySuggestionErrorKey: "Have you specified a valid 'certificateUrl'?", - ] - ) + case invalidAppCert + case keyRequestCreationFailed + case persistableKeyRequestFailed + case embeddedKeyExtractionFailed + case offlineDRMNotSupported + case unsupportedDRMType + case simulatorDRMNotSupported - static let noSPC = NSError( - domain: "RCTVideo", - code: RCTVideoError.noSPC.rawValue, - userInfo: [ - NSLocalizedDescriptionKey: "Error obtaining license.", - NSLocalizedFailureReasonErrorKey: "No spc received.", - NSLocalizedRecoverySuggestionErrorKey: "Check your DRM config.", - ] - ) + var errorCode: Int { + switch self { + case .fromJSPart: + return 1000 + case .noLicenseServerURL: + return 1001 + case .licenseRequestFailed: + return 1002 + case .noDataFromLicenseRequest: + return 1003 + case .noSPC: + return 1004 + case .noCertificateData: + return 1005 + case .noCertificateURL: + return 1006 + case .noDRMData: + return 1007 + case .invalidContentId: + return 1008 + case .invalidAppCert: + return 1009 + case .keyRequestCreationFailed: + return 1010 + case .persistableKeyRequestFailed: + return 1011 + case .embeddedKeyExtractionFailed: + return 1012 + case .offlineDRMNotSupported: + return 1013 + case .unsupportedDRMType: + return 1014 + case .simulatorDRMNotSupported: + return 1015 + } + } +} - static let noLicenseServerURL = NSError( - domain: "RCTVideo", - code: RCTVideoError.noLicenseServerURL.rawValue, - userInfo: [ - NSLocalizedDescriptionKey: "Error obtaining DRM License.", - NSLocalizedFailureReasonErrorKey: "No license server URL has been found.", - NSLocalizedRecoverySuggestionErrorKey: "Did you specified the prop licenseServer?", - ] - ) +// MARK: LocalizedError - static let noDataFromLicenseRequest = NSError( - domain: "RCTVideo", - code: RCTVideoError.noDataFromLicenseRequest.rawValue, - userInfo: [ - NSLocalizedDescriptionKey: "Error obtaining DRM license.", - NSLocalizedFailureReasonErrorKey: "No data received from the license server.", - NSLocalizedRecoverySuggestionErrorKey: "Is the licenseServer ok?", - ] - ) +extension RCTVideoError: LocalizedError { + var errorDescription: String? { + switch self { + case let .fromJSPart(error): + return NSLocalizedString("Error from JavaScript: \(error)", comment: "") + case .noLicenseServerURL: + return NSLocalizedString("No license server URL provided", comment: "") + case let .licenseRequestFailed(statusCode): + return NSLocalizedString("License request failed with status code: \(statusCode)", comment: "") + case .noDataFromLicenseRequest: + return NSLocalizedString("No data received from license server", comment: "") + case .noSPC: + return NSLocalizedString("Failed to create Server Playback Context (SPC)", comment: "") + case .noCertificateData: + return NSLocalizedString("No certificate data obtained", comment: "") + case .noCertificateURL: + return NSLocalizedString("No certificate URL provided", comment: "") + case .noDRMData: + return NSLocalizedString("No DRM data available", comment: "") + case .invalidContentId: + return NSLocalizedString("Invalid content ID", comment: "") + case .invalidAppCert: + return NSLocalizedString("Invalid application certificate", comment: "") + case .keyRequestCreationFailed: + return NSLocalizedString("Failed to create content key request", comment: "") + case .persistableKeyRequestFailed: + return NSLocalizedString("Failed to create persistable content key request", comment: "") + case .embeddedKeyExtractionFailed: + return NSLocalizedString("Failed to extract embedded key", comment: "") + case .offlineDRMNotSupported: + return NSLocalizedString("Offline DRM is not supported, see https://github.com/TheWidlarzGroup/react-native-video/issues/3539", comment: "") + case .unsupportedDRMType: + return NSLocalizedString("Unsupported DRM type", comment: "") + case .simulatorDRMNotSupported: + return NSLocalizedString("DRM on simulators is not supported", comment: "") + } + } - static func licenseRequestNotOk(_ statusCode: Int) -> NSError { - return NSError( - domain: "RCTVideo", - code: RCTVideoError.licenseRequestNotOk.rawValue, - userInfo: [ - NSLocalizedDescriptionKey: "Error obtaining license.", - NSLocalizedFailureReasonErrorKey: String( - format: "License server responded with status code %li", - statusCode - ), - NSLocalizedRecoverySuggestionErrorKey: "Did you send the correct data to the license Server? Is the server ok?", - ] - ) + var failureReason: String? { + switch self { + case .fromJSPart: + return NSLocalizedString("An error occurred in the JavaScript part of the application.", comment: "") + case .noLicenseServerURL: + return NSLocalizedString("The license server URL is missing in the DRM configuration.", comment: "") + case .licenseRequestFailed: + return NSLocalizedString("The license server responded with an error status code.", comment: "") + case .noDataFromLicenseRequest: + return NSLocalizedString("The license server did not return any data.", comment: "") + case .noSPC: + return NSLocalizedString("Failed to generate the Server Playback Context (SPC) for the content.", comment: "") + case .noCertificateData: + return NSLocalizedString("Unable to retrieve certificate data from the specified URL.", comment: "") + case .noCertificateURL: + return NSLocalizedString("The certificate URL is missing in the DRM configuration.", comment: "") + case .noDRMData: + return NSLocalizedString("The required DRM data is not available or is invalid.", comment: "") + case .invalidContentId: + return NSLocalizedString("The content ID provided is not valid or recognized.", comment: "") + case .invalidAppCert: + return NSLocalizedString("The application certificate is invalid or not recognized.", comment: "") + case .keyRequestCreationFailed: + return NSLocalizedString("Unable to create a content key request for DRM.", comment: "") + case .persistableKeyRequestFailed: + return NSLocalizedString("Failed to create a persistable content key request for offline playback.", comment: "") + case .embeddedKeyExtractionFailed: + return NSLocalizedString("Unable to extract the embedded key from the custom scheme URL.", comment: "") + case .offlineDRMNotSupported: + return NSLocalizedString("You tried to use Offline DRM but it is not supported yet", comment: "") + case .unsupportedDRMType: + return NSLocalizedString("You tried to use unsupported DRM type", comment: "") + case .simulatorDRMNotSupported: + return NSLocalizedString("You tried to DRM on a simulator", comment: "") + } } - static func fromJSPart(_ error: String) -> NSError { - return NSError(domain: "RCTVideo", - code: RCTVideoError.fromJSPart.rawValue, - userInfo: [ - NSLocalizedDescriptionKey: error, - NSLocalizedFailureReasonErrorKey: error, - NSLocalizedRecoverySuggestionErrorKey: error, - ]) + var recoverySuggestion: String? { + switch self { + case .fromJSPart: + return NSLocalizedString("Check the JavaScript logs for more details and fix any issues in the JS code.", comment: "") + case .noLicenseServerURL: + return NSLocalizedString("Ensure that you have specified the 'licenseServer' property in the DRM configuration.", comment: "") + case .licenseRequestFailed: + return NSLocalizedString("Verify that the license server is functioning correctly and that you're sending the correct data.", comment: "") + case .noDataFromLicenseRequest: + return NSLocalizedString("Check if the license server is operational and responding with the expected data.", comment: "") + case .noSPC: + return NSLocalizedString("Verify that the content key request is properly configured and that the DRM setup is correct.", comment: "") + case .noCertificateData: + return NSLocalizedString("Check if the certificate URL is correct and accessible, and that it returns valid certificate data.", comment: "") + case .noCertificateURL: + return NSLocalizedString("Make sure you have specified the 'certificateUrl' property in the DRM configuration.", comment: "") + case .noDRMData: + return NSLocalizedString("Ensure that you have provided all necessary DRM-related data in the configuration.", comment: "") + case .invalidContentId: + return NSLocalizedString("Verify that the content ID is correct and matches the expected format for your DRM system.", comment: "") + case .invalidAppCert: + return NSLocalizedString("Check if the application certificate is valid and properly formatted for your DRM system.", comment: "") + case .keyRequestCreationFailed: + return NSLocalizedString("Review your DRM configuration and ensure all required parameters are correctly set.", comment: "") + case .persistableKeyRequestFailed: + return NSLocalizedString("Verify that offline playback is supported and properly configured for your content.", comment: "") + case .embeddedKeyExtractionFailed: + return NSLocalizedString("Check if the embedded key is present in the URL and the custom scheme is correctly implemented.", comment: "") + case .offlineDRMNotSupported: + return NSLocalizedString("Check if localSourceEncryptionKeyScheme is set", comment: "") + case .unsupportedDRMType: + return NSLocalizedString("Verify that you are using fairplay (on Apple devices)", comment: "") + case .simulatorDRMNotSupported: + return NSLocalizedString("You need to test DRM content on real device", comment: "") + } } +} + +// MARK: - RCTVideoErrorHandler - static let invalidContentId = NSError( - domain: "RCTVideo", - code: RCTVideoError.invalidContentId.rawValue, - userInfo: [ - NSLocalizedDescriptionKey: "Error obtaining DRM license.", - NSLocalizedFailureReasonErrorKey: "No valide content Id received", - NSLocalizedRecoverySuggestionErrorKey: "Is the contentId and url ok?", +enum RCTVideoErrorHandler { + static func createError(from error: RCTVideoError) -> [String: Any] { + return [ + "code": error.errorCode, + "localizedDescription": error.localizedDescription, + "localizedFailureReason": error.failureReason ?? "", + "localizedRecoverySuggestion": error.recoverySuggestion ?? "", + "domain": "RCTVideo", ] - ) + } } diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index 908e3eb881..21027ca8e3 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -17,7 +17,6 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _playerViewController: RCTVideoPlayerViewController? private var _videoURL: NSURL? - private var _localSourceEncryptionKeyScheme: String? /* Required to publish events */ private var _eventDispatcher: RCTEventDispatcher? @@ -97,7 +96,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _didRequestAds = false private var _adPlaying = false - private var _resouceLoaderDelegate: RCTResourceLoaderDelegate? + private lazy var _drmManager: DRMManager? = DRMManager() private var _playerObserver: RCTPlayerObserver = .init() #if USE_VIDEO_CACHING @@ -421,7 +420,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH "type": _source?.type ?? NSNull(), "isNetwork": NSNumber(value: _source?.isNetwork ?? false), ], - "drm": source.drm?.json ?? NSNull(), + "drm": source.drm.json ?? NSNull(), "target": reactTag as Any, ]) @@ -458,14 +457,17 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } #endif - if source.drm != nil || _localSourceEncryptionKeyScheme != nil { - _resouceLoaderDelegate = RCTResourceLoaderDelegate( + if source.drm.json != nil { + if _drmManager == nil { + _drmManager = DRMManager() + } + + _drmManager?.createContentKeyRequest( asset: asset, - drm: source.drm, - localSourceEncryptionKeyScheme: _localSourceEncryptionKeyScheme, + drmParams: source.drm, + reactTag: reactTag, onVideoError: onVideoError, - onGetLicense: onGetLicense, - reactTag: reactTag + onGetLicense: onGetLicense ) } @@ -562,7 +564,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } self.removePlayerLayer() self._playerObserver.player = nil - self._resouceLoaderDelegate = nil + self._drmManager = nil self._playerObserver.playerItem = nil // perform on next run loop, otherwise other passed react-props may not be set @@ -594,13 +596,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH DispatchQueue.global(qos: .default).async(execute: initializeSource) } - @objc - func setLocalSourceEncryptionKeyScheme(_ keyScheme: String) { - _localSourceEncryptionKeyScheme = keyScheme - } - func playerItemPrepareText(source: VideoSource, asset: AVAsset!, assetOptions: NSDictionary?, uri: String) async -> AVPlayerItem { - if source.textTracks.isEmpty != true || uri.hasSuffix(".m3u8") { + if source.textTracks.isEmpty == true || uri.hasSuffix(".m3u8") { return await self.playerItemPropegateMetadata(AVPlayerItem(asset: asset)) } @@ -1295,7 +1292,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH ReactNativeVideoManager.shared.onInstanceRemoved(id: instanceId, player: _player as Any) _player = nil - _resouceLoaderDelegate = nil + _drmManager = nil _playerObserver.clearPlayer() self.removePlayerLayer() @@ -1328,12 +1325,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH ) } - func setLicenseResult(_ license: String!, _ licenseUrl: String!) { - _resouceLoaderDelegate?.setLicenseResult(license, licenseUrl) + func setLicenseResult(_ license: String, _ licenseUrl: String) { + _drmManager?.setJSLicenseResult(license: license, licenseUrl: licenseUrl) } - func setLicenseResultError(_ error: String!, _ licenseUrl: String!) { - _resouceLoaderDelegate?.setLicenseResultError(error, licenseUrl) + func setLicenseResultError(_ error: String, _ licenseUrl: String) { + _drmManager?.setJSLicenseError(error: error, licenseUrl: licenseUrl) } // MARK: - RCTPlayerObserverHandler diff --git a/src/Video.tsx b/src/Video.tsx index c1f251a187..8f8dd6c04b 100644 --- a/src/Video.tsx +++ b/src/Video.tsx @@ -119,6 +119,7 @@ const Video = forwardRef( onTextTrackDataChanged, onVideoTracks, onAspectRatio, + localSourceEncryptionKeyScheme, ...rest }, ref, @@ -189,6 +190,9 @@ const Video = forwardRef( base64Certificate: selectedDrm.base64Certificate, useExternalGetLicense: !!selectedDrm.getLicense, multiDrm: selectedDrm.multiDrm, + localSourceEncryptionKeyScheme: + selectedDrm.localSourceEncryptionKeyScheme || + localSourceEncryptionKeyScheme, }; let _cmcd: NativeCmcdConfiguration | undefined; @@ -238,7 +242,13 @@ const Video = forwardRef( textTracksAllowChunklessPreparation: resolvedSource.textTracksAllowChunklessPreparation, }; - }, [drm, source, textTracks, contentStartTime]); + }, [ + drm, + source, + textTracks, + contentStartTime, + localSourceEncryptionKeyScheme, + ]); const _selectedTextTrack = useMemo(() => { if (!selectedTextTrack) { diff --git a/src/specs/VideoNativeComponent.ts b/src/specs/VideoNativeComponent.ts index f210cfccfb..34a50438ad 100644 --- a/src/specs/VideoNativeComponent.ts +++ b/src/specs/VideoNativeComponent.ts @@ -62,6 +62,7 @@ type Drm = Readonly<{ base64Certificate?: boolean; // ios default: false useExternalGetLicense?: boolean; // ios multiDrm?: WithDefault; // android + localSourceEncryptionKeyScheme?: string; // ios }>; type CmcdMode = WithDefault; @@ -341,7 +342,6 @@ export interface VideoNativeProps extends ViewProps { fullscreenOrientation?: WithDefault; progressUpdateInterval?: Float; restoreUserInterfaceForPIPStopCompletionHandler?: boolean; - localSourceEncryptionKeyScheme?: string; debug?: DebugConfig; showNotificationControls?: WithDefault; // Android, iOS bufferConfig?: BufferConfig; // Android diff --git a/src/types/video.ts b/src/types/video.ts index 71304080d6..9e25e25ea5 100644 --- a/src/types/video.ts +++ b/src/types/video.ts @@ -81,6 +81,7 @@ export type Drm = Readonly<{ certificateUrl?: string; // ios base64Certificate?: boolean; // ios default: false multiDrm?: boolean; // android + localSourceEncryptionKeyScheme?: string; // ios /* eslint-disable @typescript-eslint/no-unused-vars */ getLicense?: ( spcBase64: string, @@ -321,6 +322,7 @@ export interface ReactVideoProps extends ReactVideoEvents, ViewProps { /** @deprecated Use viewType*/ useSecureView?: boolean; // Android volume?: number; + /** @deprecated use **localSourceEncryptionKeyScheme** key in **drm** props instead */ localSourceEncryptionKeyScheme?: string; debug?: DebugConfig; allowsExternalPlayback?: boolean; // iOS