[video_player] Add DRM support for Android (Widevine) and iOS (FairPlay) - #11115
[video_player] Add DRM support for Android (Widevine) and iOS (FairPlay)#11115nateshmbhat wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request adds comprehensive DRM support for Android (Widevine) and iOS (FairPlay) to the video_player package. It introduces new data models for DRM configuration in the platform interface, updates the VideoPlayerController to handle these configurations, and provides native implementations for both platforms. The changes are well-tested with new unit tests and a new example screen for manual testing.
| - (void)handleLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest { | ||
| NSError *certificateError; | ||
| NSData *certificateData = [NSData dataWithContentsOfURL:self.certificateURL | ||
| options:0 | ||
| error:&certificateError]; | ||
| if (certificateData == nil) { | ||
| [loadingRequest finishLoadingWithError:certificateError]; | ||
| return; | ||
| } | ||
|
|
||
| NSString *requestContentId = self.contentId; | ||
| if (requestContentId.length == 0) { | ||
| requestContentId = loadingRequest.request.URL.absoluteString; | ||
| } | ||
| NSData *contentIdentifierData = [requestContentId dataUsingEncoding:NSUTF8StringEncoding]; | ||
| if (contentIdentifierData == nil) { | ||
| [loadingRequest finishLoadingWithError:nil]; | ||
| return; | ||
| } | ||
|
|
||
| NSError *spcError; | ||
| NSData *spcData = [loadingRequest streamingContentKeyRequestDataForApp:certificateData | ||
| contentIdentifier:contentIdentifierData | ||
| options:nil | ||
| error:&spcError]; | ||
| if (spcData == nil) { | ||
| [loadingRequest finishLoadingWithError:spcError]; | ||
| return; | ||
| } | ||
|
|
||
| NSMutableURLRequest *licenseRequest = [NSMutableURLRequest requestWithURL:self.licenseURL]; | ||
| [licenseRequest setHTTPMethod:@"POST"]; | ||
| [licenseRequest setValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"]; | ||
| for (NSString *headerName in self.licenseHeaders) { | ||
| [licenseRequest setValue:self.licenseHeaders[headerName] forHTTPHeaderField:headerName]; | ||
| } | ||
| [licenseRequest setHTTPBody:spcData]; | ||
|
|
||
| NSURLSessionDataTask *task = [[NSURLSession sharedSession] | ||
| dataTaskWithRequest:licenseRequest | ||
| completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response, | ||
| NSError *_Nullable error) { | ||
| if (error != nil) { | ||
| [loadingRequest finishLoadingWithError:error]; | ||
| return; | ||
| } | ||
| if (data == nil || data.length == 0) { | ||
| [loadingRequest finishLoadingWithError:nil]; | ||
| return; | ||
| } | ||
| [loadingRequest.dataRequest respondWithData:data]; | ||
| [loadingRequest finishLoading]; | ||
| }]; | ||
| [task resume]; | ||
| } | ||
|
|
||
| @end |
There was a problem hiding this comment.
Using [NSData dataWithContentsOfURL:options:error:] for fetching the FairPlay certificate performs a synchronous network request. This can block the resource loader's dispatch queue, potentially impacting responsiveness.
It's recommended to use an asynchronous NSURLSessionDataTask for all network operations, similar to how the license request is handled later in this method. This ensures that all network I/O is non-blocking.
- (void)handleLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest {
NSURLSessionDataTask *certTask = [[NSURLSession sharedSession]
dataTaskWithURL:self.certificateURL
completionHandler:^(NSData *_Nullable certificateData, NSURLResponse *_Nullable response,
NSError *_Nullable certificateError) {
if (certificateError) {
[loadingRequest finishLoadingWithError:certificateError];
return;
}
if (!certificateData) {
[loadingRequest finishLoadingWithError:nil];
return;
}
NSString *requestContentId = self.contentId;
if (requestContentId.length == 0) {
requestContentId = loadingRequest.request.URL.absoluteString;
}
NSData *contentIdentifierData = [requestContentId dataUsingEncoding:NSUTF8StringEncoding];
if (contentIdentifierData == nil) {
[loadingRequest finishLoadingWithError:nil];
return;
}
NSError *spcError;
NSData *spcData =
[loadingRequest streamingContentKeyRequestDataForApp:certificateData
contentIdentifier:contentIdentifierData
options:nil
error:&spcError];
if (spcData == nil) {
[loadingRequest finishLoadingWithError:spcError];
return;
}
NSMutableURLRequest *licenseRequest = [NSMutableURLRequest requestWithURL:self.licenseURL];
[licenseRequest setHTTPMethod:@"POST"];
[licenseRequest setValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"];
for (NSString *headerName in self.licenseHeaders) {
[licenseRequest setValue:self.licenseHeaders[headerName] forHTTPHeaderField:headerName];
}
[licenseRequest setHTTPBody:spcData];
NSURLSessionDataTask *task = [[NSURLSession sharedSession]
dataTaskWithRequest:licenseRequest
completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response,
NSError *_Nullable error) {
if (error != nil) {
[loadingRequest finishLoadingWithError:error];
return;
}
if (data == nil || data.length == 0) {
[loadingRequest finishLoadingWithError:nil];
return;
}
[loadingRequest.dataRequest respondWithData:data];
[loadingRequest finishLoading];
}];
[task resume];
}];
[certTask resume];
}|
Thanks for the contribution! This is adding non-trivial complexity and scope to the plugin (e.g., the iOS/macOS implementation now seems to be managing its own network requests which has not previously been the case), so the first step here would be a design document explaining the changes at a high level and the reasons for them, and discussing any tradeoffs and limitations, so that everyone reviewing already has context and agreement about the general approach. Once that's done and reviewed, then the PR can be reviewed. Also, I want to set expectations very clearly up front that since this a large, complex PR, and this is not currently a critical priority for the team, code reviews will likely take significant time. Please consider whether that is going to be a significant issue for you again before starting the review process. |
|
Per review request, I have prepared and shared a design document for this proposal:
The doc covers the high-level approach, rationale, tradeoffs, and known limitations. I’ll wait for design feedback/alignment before continuing with code-review updates on this PR. |
|
I have updated and moved the design doc based on the feedback left on the previous draft. Current design doc: https://docs.google.com/document/d/1T4LRRsicr_JuSkXBAXEcv7-9BnYPhmwgYnr1d6G6_o8/edit?tab=t.0 The new draft is template-based and incorporates the earlier comments around scope, tradeoffs, limitations, and additional AVFoundation/FairPlay context. Please use the new doc for any further design feedback. |
## Description Adds a `flutter.dev/go` redirect for the DRM support design document for `video_player`. The new go link points to the public design doc covering proposed first-party DRM support for: - Android Widevine - iOS FairPlay This is needed so the design doc follows the Flutter design doc process and can be referenced from the tracking issue and review discussions. ## Related Issues - Design doc tracking issue: flutter/flutter#182894 - Related implementation PR: flutter/packages#11115 ## Testing - Verified the redirect entry was added in the expected location. - Verified the destination URL points to the public Google Doc. ## Presubmit checklist - [x] If you are unwilling, or unable, to sign the CLA, even for a _tiny_, one-word PR, please file an issue instead of a PR. - [x] If this PR is not meant to land until a future stable release, mark it as draft with an explanation. - [x] This PR follows the [Google Developer Documentation Style Guidelines](https://developers.google.com/style)—for example, it doesn't use _i.e._ or _e.g._, and it avoids _I_ and _we_ (first-person pronouns). - [x] This PR uses [semantic line breaks](https://github.com/dart-lang/site-shared/blob/main/doc/writing-for-dart-and-flutter-websites.md#semantic-line-breaks) of 80 characters or fewer.
|
From triage: @nateshmbhat Are you still planning on responding to the design doc feedback, in order to unblock this? |
yes @stuartmorgan-g i'm planning to take this forward and do whatever is needed. |
Have replied to your comments in the doc |
|
From triage: Other leads have now been tagged in to review the design doc; this is just waiting on that feedback. |
8ed6c79 to
9d8ae61
Compare
|
Design doc is signed off, so I've updated this PR to match it. Two things changed since the last version: 1. DRM configuration types moved out of the platform interface. Per the doc thread with @tarrinneal and @stuartmorgan-g, this now follows the 2. Rebased onto current main. The branch predated the Swift conversion and the Pigeon plugin/instance message split in Also added, following the doc's documentation and testing plans: a DRM section in the The temporary |
9d8ae61 to
08a3097
Compare
There was a problem hiding this comment.
Code Review
This pull request introduces DRM support for network streams in the video_player package, adding platform-specific configurations for Widevine on Android and FairPlay on iOS/macOS. The review feedback suggests optimizing the FairPlay implementation by caching the application certificate to prevent redundant network requests and capturing self weakly in asynchronous network callbacks to avoid potential retain cycles.
| @interface FVPFairPlayResourceLoaderDelegate () | ||
| @property(nonatomic, copy, readonly) NSURL *certificateURL; | ||
| @property(nonatomic, copy, readonly) NSURL *licenseURL; | ||
| @property(nonatomic, copy, readonly) NSDictionary<NSString *, NSString *> *licenseHeaders; | ||
| @property(nonatomic, copy, readonly, nullable) NSString *contentId; | ||
| @property(nonatomic, readonly) NSURLSession *session; | ||
| @end |
There was a problem hiding this comment.
To optimize performance and avoid redundant network requests, we should cache the FairPlay application certificate once it is successfully fetched. The certificate is static and does not change during playback. Let's add an atomic property to store the cached certificate data.
| @interface FVPFairPlayResourceLoaderDelegate () | |
| @property(nonatomic, copy, readonly) NSURL *certificateURL; | |
| @property(nonatomic, copy, readonly) NSURL *licenseURL; | |
| @property(nonatomic, copy, readonly) NSDictionary<NSString *, NSString *> *licenseHeaders; | |
| @property(nonatomic, copy, readonly, nullable) NSString *contentId; | |
| @property(nonatomic, readonly) NSURLSession *session; | |
| @end | |
| @interface FVPFairPlayResourceLoaderDelegate () | |
| @property(nonatomic, copy, readonly) NSURL *certificateURL; | |
| @property(nonatomic, copy, readonly) NSURL *licenseURL; | |
| @property(nonatomic, copy, readonly) NSDictionary<NSString *, NSString *> *licenseHeaders; | |
| @property(nonatomic, copy, readonly, nullable) NSString *contentId; | |
| @property(nonatomic, readonly) NSURLSession *session; | |
| @property(atomic, strong, nullable) NSData *cachedCertificate; | |
| @end |
| - (void)loadContentKeyForRequest:(AVAssetResourceLoadingRequest *)loadingRequest { | ||
| [[self.session dataTaskWithURL:self.certificateURL | ||
| completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response, | ||
| NSError *_Nullable error) { | ||
| NSError *validationError = FVPValidateResponse( | ||
| data, response, error, FVPFairPlayErrorCodeCertificateUnavailable, | ||
| @"Unable to fetch the FairPlay application certificate"); | ||
| if (validationError != nil) { | ||
| [loadingRequest finishLoadingWithError:validationError]; | ||
| return; | ||
| } | ||
| [self requestLicenseForRequest:loadingRequest certificate:data]; | ||
| }] resume]; | ||
| } |
There was a problem hiding this comment.
Use the cached certificate if it is already available, and store it upon a successful fetch to prevent redundant network requests for subsequent key requests. Additionally, capture self weakly to avoid a temporary retain cycle and prevent delayed deallocation of the delegate if the player is disposed while a certificate request is in progress.
- (void)loadContentKeyForRequest:(AVAssetResourceLoadingRequest *)loadingRequest {
NSData *cachedCertificate = self.cachedCertificate;
if (cachedCertificate != nil) {
[self requestLicenseForRequest:loadingRequest certificate:cachedCertificate];
return;
}
__weak typeof(self) weakSelf = self;
[[self.session dataTaskWithURL:self.certificateURL
completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response,
NSError *_Nullable error) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (strongSelf == nil) {
return;
}
NSError *validationError = FVPValidateResponse(
data, response, error, FVPFairPlayErrorCodeCertificateUnavailable,
@"Unable to fetch the FairPlay application certificate");
if (validationError != nil) {
[loadingRequest finishLoadingWithError:validationError];
return;
}
strongSelf.cachedCertificate = data;
[strongSelf requestLicenseForRequest:loadingRequest certificate:data];
}] resume];
}| - (void)requestLicenseForRequest:(AVAssetResourceLoadingRequest *)loadingRequest | ||
| certificate:(NSData *)certificate { | ||
| // The content ID defaults to the skd:// URL that AVFoundation asked to resolve, which is what | ||
| // most providers key their licenses on. | ||
| NSString *contentId = | ||
| self.contentId.length > 0 ? self.contentId : loadingRequest.request.URL.absoluteString; | ||
| NSData *contentIdData = [contentId dataUsingEncoding:NSUTF8StringEncoding]; | ||
|
|
||
| NSError *spcError; | ||
| NSData *spcData = [loadingRequest streamingContentKeyRequestDataForApp:certificate | ||
| contentIdentifier:contentIdData | ||
| options:nil | ||
| error:&spcError]; | ||
| if (spcData == nil) { | ||
| [loadingRequest finishLoadingWithError:spcError]; | ||
| return; | ||
| } | ||
|
|
||
| NSMutableURLRequest *licenseRequest = [NSMutableURLRequest requestWithURL:self.licenseURL]; | ||
| licenseRequest.HTTPMethod = @"POST"; | ||
| [licenseRequest setValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"]; | ||
| [self.licenseHeaders | ||
| enumerateKeysAndObjectsUsingBlock:^(NSString *name, NSString *value, BOOL *stop) { | ||
| [licenseRequest setValue:value forHTTPHeaderField:name]; | ||
| }]; | ||
| licenseRequest.HTTPBody = spcData; | ||
|
|
||
| [[self.session dataTaskWithRequest:licenseRequest | ||
| completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response, | ||
| NSError *_Nullable error) { | ||
| NSError *validationError = FVPValidateResponse( | ||
| data, response, error, FVPFairPlayErrorCodeLicenseUnavailable, | ||
| @"Unable to fetch the FairPlay license"); | ||
| if (validationError != nil) { | ||
| [loadingRequest finishLoadingWithError:validationError]; | ||
| return; | ||
| } | ||
| [loadingRequest.dataRequest respondWithData:data]; | ||
| [loadingRequest finishLoading]; | ||
| }] resume]; | ||
| } |
There was a problem hiding this comment.
To avoid a temporary retain cycle and prevent delayed deallocation of the delegate when the player is disposed while a license request is in progress, we should capture self weakly in the completion handler.
- (void)requestLicenseForRequest:(AVAssetResourceLoadingRequest *)loadingRequest
certificate:(NSData *)certificate {
// The content ID defaults to the skd:// URL that AVFoundation asked to resolve, which is what
// most providers key their licenses on.
NSString *contentId =
self.contentId.length > 0 ? self.contentId : loadingRequest.request.URL.absoluteString;
NSData *contentIdData = [contentId dataUsingEncoding:NSUTF8StringEncoding];
NSError *spcError;
NSData *spcData = [loadingRequest streamingContentKeyRequestDataForApp:certificate
contentIdentifier:contentIdData
options:nil
error:&spcError];
if (spcData == nil) {
[loadingRequest finishLoadingWithError:spcError];
return;
}
NSMutableURLRequest *licenseRequest = [NSMutableURLRequest requestWithURL:self.licenseURL];
licenseRequest.HTTPMethod = @"POST";
[licenseRequest setValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"];
[self.licenseHeaders
enumerateKeysAndObjectsUsingBlock:^(NSString *name, NSString *value, BOOL *stop) {
[licenseRequest setValue:value forHTTPHeaderField:name];
}];
licenseRequest.HTTPBody = spcData;
__weak typeof(self) weakSelf = self;
[[self.session dataTaskWithRequest:licenseRequest
completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response,
NSError *_Nullable error) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (strongSelf == nil) {
return;
}
NSError *validationError = FVPValidateResponse(
data, response, error, FVPFairPlayErrorCodeLicenseUnavailable,
@"Unable to fetch the FairPlay license");
if (validationError != nil) {
[loadingRequest finishLoadingWithError:validationError];
return;
}
[loadingRequest.dataRequest respondWithData:data];
[loadingRequest finishLoading];
}] resume];
}Implements the design agreed in flutter/flutter#182894. Adds `VideoDrmConfiguration` to the platform interface as a common base type, and `DataSource.drmConfiguration` to carry it. Following the shared_preferences model for platform-specific options, the concrete configurations live in the implementation packages: * `WidevineDrmConfiguration` in video_player_android, mapped onto Media3's `MediaItem.DrmConfiguration`. * `FairPlayDrmConfiguration` in video_player_avfoundation, serviced by a new `FVPFairPlayResourceLoaderDelegate` that fetches the application certificate, generates the SPC, and exchanges it for a CKC. Each implementation recognizes only its own subtype, and rejects both unsupported subtypes and non-network sources with an `ArgumentError`. `VideoPlayerController.networkUrl` gains an optional `drmConfiguration`; everything else is unchanged, and DRM is entirely opt-in.
08a3097 to
e4faac3
Compare
Addresses lifetime issues found in review of the FairPlay resource loader delegate. The delegate started untracked NSURLSession tasks and never implemented resourceLoader:didCancelLoadingRequest:, so a key request cancelled by AVFoundation - on seek, item replacement, or teardown - kept running and its completion handler still finished the cancelled request. Requests are now tracked alongside the task servicing them, cancellation stops that task, and every completion path checks that the request is still live before finishing it. The completion blocks also captured the delegate strongly, and the delegate owns the session that retains those blocks. A stalled request therefore kept the delegate, and through it the player item, alive until the network timed out. The blocks now capture it weakly, and dealloc invalidates the session so outstanding tasks are cancelled. Finally, FVPVideoPlayer released the AVPlayer's item on dispose but held onto the wrapper, which owns the resource loader delegate, until the player itself was deallocated. Dispose now releases it.
Adds DRM support to
video_player: Widevine on Android, FairPlay on iOS and macOS.Implements the design agreed on in flutter/flutter#182894 (design doc).
Fixes flutter/flutter#24923
API
A network source can now be given a DRM configuration:
Per design-doc review feedback, the configuration types follow the
shared_preferences_asyncmodel for platform-specific options rather than living in the platform interface:video_player_platform_interfaceVideoDrmConfiguration(abstract base),DataSource.drmConfigurationvideo_player_androidWidevineDrmConfigurationvideo_player_avfoundationFairPlayDrmConfigurationvideo_playerdrmConfigurationon the network constructorsAn app that uses DRM therefore depends on the implementation package whose configuration it constructs. Each implementation recognizes only its own subtype via a runtime type check, and rejects unsupported subtypes and non-network sources with an
ArgumentError.Implementation
Android delegates entirely to Media3: the typed configuration is mapped onto
MediaItem.DrmConfigurationwithC.WIDEVINE_UUID, so the plugin owns no license networking.iOS/macOS has to participate in the key exchange, because AVFoundation has no declarative equivalent. A new
FVPFairPlayResourceLoaderDelegateis installed on theAVURLAsset; it services theskd://key requests by fetching the application certificate, generating the SPC, POSTing it to the license server with the supplied headers, and returning the CKC. Only the standard FairPlay exchange is supported (SPC asapplication/octet-stream, response body used as the CKC as-is).AVAssetResourceLoaderholds its delegate weakly, so the delegate is owned by the wrapped asset and player item, andFVPVideoPlayernow retains its player item — ownership is described in the design doc's lifecycle section.Limitations
Documented in the
video_playerREADME:VideoViewType.platformView; in texture mode audio plays but frames are blank (protected buffers can't be copied into the Flutter texture).Testing
video_player, Android and AVFoundation Dart unit tests for forwarding, defaults, and the rejection paths.MediaItem.DrmConfigurationand that no DRM configuration is added by default.Pre-Review Checklist
[shared_preferences]pubspec.yamlwith an appropriate new version according to the pub versioning philosophy, or this PR is exempt from version changes.CHANGELOG.mdto add a description of the change, following repository CHANGELOG style, or this PR is exempt from CHANGELOG changes.///).