feat(iOS): rewrite DRM Module (#4136)

* minimal api

* add suport for `getLicense`

* update logic for obtaining `assetId`

* add support for localSourceEncryptionKeyScheme

* fix typo

* fix pendingLicenses key bug

* lint code

* code clean

* code clean

* remove old files

* fix tvOS build

* fix errors loop

* move `localSourceEncryptionKeyScheme` into drm params

* add check for drm type

* use DebugLog

* lint

* update docs

* lint code

* fix bad rebase

* update docs

* fix crashes on simulators

* show error on simulator when using DRM

* fix typos

* code clean
This commit is contained in:
Krzysztof Moch 2024-09-20 17:46:10 +02:00 committed by GitHub
parent c96f7d41f3
commit 0e4c95def9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 578 additions and 484 deletions

View File

@ -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`
<PlatformsList types={['iOS']} />
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

View File

@ -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`
<PlatformsList types={['iOS']} />
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
<PlatformsList types={['Android', 'iOS']} />
<PlatformsList types={['Android', 'iOS', 'visionOS', 'tvOS']} />
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

View File

@ -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
}
}

View File

@ -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

View File

@ -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))
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
)
}
}

View File

@ -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
case invalidAppCert
case keyRequestCreationFailed
case persistableKeyRequestFailed
case embeddedKeyExtractionFailed
case offlineDRMNotSupported
case unsupportedDRMType
case simulatorDRMNotSupported
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
}
}
}
// MARK: LocalizedError
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: "")
}
}
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: "")
}
}
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
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 func createError(from error: RCTVideoError) -> [String: Any] {
return [
"code": error.errorCode,
"localizedDescription": error.localizedDescription,
"localizedFailureReason": error.failureReason ?? "",
"localizedRecoverySuggestion": error.recoverySuggestion ?? "",
"domain": "RCTVideo",
]
)
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'?",
]
)
static let noSPC = NSError(
domain: "RCTVideo",
code: RCTVideoError.noSPC.rawValue,
userInfo: [
NSLocalizedDescriptionKey: "Error obtaining license.",
NSLocalizedFailureReasonErrorKey: "No spc received.",
NSLocalizedRecoverySuggestionErrorKey: "Check your DRM config.",
]
)
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?",
]
)
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?",
]
)
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?",
]
)
}
static func fromJSPart(_ error: String) -> NSError {
return NSError(domain: "RCTVideo",
code: RCTVideoError.fromJSPart.rawValue,
userInfo: [
NSLocalizedDescriptionKey: error,
NSLocalizedFailureReasonErrorKey: error,
NSLocalizedRecoverySuggestionErrorKey: error,
])
}
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?",
]
)
}

View File

@ -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,11 +596,6 @@ 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") {
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

View File

@ -119,6 +119,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
onTextTrackDataChanged,
onVideoTracks,
onAspectRatio,
localSourceEncryptionKeyScheme,
...rest
},
ref,
@ -189,6 +190,9 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
base64Certificate: selectedDrm.base64Certificate,
useExternalGetLicense: !!selectedDrm.getLicense,
multiDrm: selectedDrm.multiDrm,
localSourceEncryptionKeyScheme:
selectedDrm.localSourceEncryptionKeyScheme ||
localSourceEncryptionKeyScheme,
};
let _cmcd: NativeCmcdConfiguration | undefined;
@ -238,7 +242,13 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
textTracksAllowChunklessPreparation:
resolvedSource.textTracksAllowChunklessPreparation,
};
}, [drm, source, textTracks, contentStartTime]);
}, [
drm,
source,
textTracks,
contentStartTime,
localSourceEncryptionKeyScheme,
]);
const _selectedTextTrack = useMemo(() => {
if (!selectedTextTrack) {

View File

@ -62,6 +62,7 @@ type Drm = Readonly<{
base64Certificate?: boolean; // ios default: false
useExternalGetLicense?: boolean; // ios
multiDrm?: WithDefault<boolean, false>; // android
localSourceEncryptionKeyScheme?: string; // ios
}>;
type CmcdMode = WithDefault<Int32, 1>;
@ -341,7 +342,6 @@ export interface VideoNativeProps extends ViewProps {
fullscreenOrientation?: WithDefault<string, 'all'>;
progressUpdateInterval?: Float;
restoreUserInterfaceForPIPStopCompletionHandler?: boolean;
localSourceEncryptionKeyScheme?: string;
debug?: DebugConfig;
showNotificationControls?: WithDefault<boolean, false>; // Android, iOS
bufferConfig?: BufferConfig; // Android

View File

@ -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