diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 3c4bf97b7b..274e4cc4b6 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -168,6 +168,7 @@ [#6725](https://github.com/google/ExoPlayer/issues/6725), [#7066](https://github.com/google/ExoPlayer/issues/7066)). * Downloads and caching: + * Add support for offline DRM playbacks. * Add builder in `DownloadRequest`. * Support passing an `Executor` to `DefaultDownloaderFactory` on which data downloads are performed. diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java index 0474a2a904..669e09ed70 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java @@ -93,7 +93,7 @@ public final class DemoUtil { } /** Returns a {@link DataSource.Factory}. */ - public static synchronized DataSource.Factory buildDataSourceFactory(Context context) { + public static synchronized DataSource.Factory getDataSourceFactory(Context context) { if (dataSourceFactory == null) { context = context.getApplicationContext(); DefaultDataSourceFactory upstreamFactory = @@ -151,7 +151,7 @@ public final class DemoUtil { getHttpDataSourceFactory(context), Executors.newFixedThreadPool(/* nThreads= */ 6)); downloadTracker = - new DownloadTracker(context, buildDataSourceFactory(context), downloadManager); + new DownloadTracker(context, getHttpDataSourceFactory(context), downloadManager); } } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java index a57d402d63..22d34a082c 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java @@ -23,9 +23,15 @@ import android.net.Uri; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.fragment.app.FragmentManager; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; +import com.google.android.exoplayer2.drm.OfflineLicenseHelper; import com.google.android.exoplayer2.offline.Download; import com.google.android.exoplayer2.offline.DownloadCursor; import com.google.android.exoplayer2.offline.DownloadHelper; @@ -33,9 +39,11 @@ import com.google.android.exoplayer2.offline.DownloadIndex; import com.google.android.exoplayer2.offline.DownloadManager; import com.google.android.exoplayer2.offline.DownloadRequest; import com.google.android.exoplayer2.offline.DownloadService; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; -import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -55,7 +63,7 @@ public class DownloadTracker { private static final String TAG = "DownloadTracker"; private final Context context; - private final DataSource.Factory dataSourceFactory; + private final HttpDataSource.Factory httpDataSourceFactory; private final CopyOnWriteArraySet listeners; private final HashMap downloads; private final DownloadIndex downloadIndex; @@ -64,9 +72,11 @@ public class DownloadTracker { @Nullable private StartDownloadDialogHelper startDownloadDialogHelper; public DownloadTracker( - Context context, DataSource.Factory dataSourceFactory, DownloadManager downloadManager) { + Context context, + HttpDataSource.Factory httpDataSourceFactory, + DownloadManager downloadManager) { this.context = context.getApplicationContext(); - this.dataSourceFactory = dataSourceFactory; + this.httpDataSourceFactory = httpDataSourceFactory; listeners = new CopyOnWriteArraySet<>(); downloads = new HashMap<>(); downloadIndex = downloadManager.getDownloadIndex(); @@ -89,6 +99,7 @@ public class DownloadTracker { return download != null && download.state != Download.STATE_FAILED; } + @Nullable public DownloadRequest getDownloadRequest(Uri uri) { Download download = downloads.get(uri); return download != null && download.state != Download.STATE_FAILED ? download.request : null; @@ -107,7 +118,8 @@ public class DownloadTracker { startDownloadDialogHelper = new StartDownloadDialogHelper( fragmentManager, - DownloadHelper.forMediaItem(context, mediaItem, renderersFactory, dataSourceFactory), + DownloadHelper.forMediaItem( + context, mediaItem, renderersFactory, httpDataSourceFactory), mediaItem); } } @@ -157,6 +169,7 @@ public class DownloadTracker { private TrackSelectionDialog trackSelectionDialog; private MappedTrackInfo mappedTrackInfo; + @Nullable private byte[] keySetId; public StartDownloadDialogHelper( FragmentManager fragmentManager, DownloadHelper downloadHelper, MediaItem mediaItem) { @@ -177,12 +190,43 @@ public class DownloadTracker { @Override public void onPrepared(@NonNull DownloadHelper helper) { + @Nullable DrmInitData drmInitData = findDrmInitData(helper); + if (drmInitData != null) { + if (Util.SDK_INT < 18) { + Toast.makeText(context, R.string.error_drm_unsupported_before_api_18, Toast.LENGTH_LONG) + .show(); + Log.e(TAG, "Downloading DRM protected content is not supported on API versions below 18"); + return; + } + // TODO(internal b/163107948): Support cases where DrmInitData are not in the manifest. + if (!hasSchemaData(drmInitData)) { + Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG) + .show(); + Log.e( + TAG, + "Downloading content where DRM scheme data is not located in the manifest is not" + + " supported"); + return; + } + try { + // TODO(internal b/163107948): Download the license on another thread to keep the UI + // thread unblocked. + fetchOfflineLicense(drmInitData); + } catch (DrmSession.DrmSessionException e) { + Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG) + .show(); + Log.e(TAG, "Failed to fetch offline DRM license", e); + return; + } + } + if (helper.getPeriodCount() == 0) { Log.d(TAG, "No periods found. Downloading entire stream."); startDownload(); downloadHelper.release(); return; } + mappedTrackInfo = downloadHelper.getMappedTrackInfo(/* periodIndex= */ 0); if (!TrackSelectionDialog.willHaveContent(mappedTrackInfo)) { Log.d(TAG, "No dialog content. Downloading entire stream."); @@ -257,8 +301,59 @@ public class DownloadTracker { } private DownloadRequest buildDownloadRequest() { - return downloadHelper.getDownloadRequest( - Util.getUtf8Bytes(checkNotNull(mediaItem.mediaMetadata.title))); + return downloadHelper + .getDownloadRequest(Util.getUtf8Bytes(checkNotNull(mediaItem.mediaMetadata.title))) + .copyWithKeySetId(keySetId); + } + + @RequiresApi(18) + private void fetchOfflineLicense(DrmInitData drmInitData) + throws DrmSession.DrmSessionException { + OfflineLicenseHelper offlineLicenseHelper = + OfflineLicenseHelper.newWidevineInstance( + mediaItem.playbackProperties.drmConfiguration.licenseUri.toString(), + httpDataSourceFactory, + new DrmSessionEventListener.EventDispatcher()); + keySetId = offlineLicenseHelper.downloadLicense(drmInitData); } } + + /** + * Returns whether any the {@link DrmInitData.SchemeData} contained in {@code drmInitData} has + * non-null {@link DrmInitData.SchemeData#data}. + */ + private static boolean hasSchemaData(DrmInitData drmInitData) { + for (int i = 0; i < drmInitData.schemeDataCount; i++) { + if (drmInitData.get(i).hasData()) { + return true; + } + } + return false; + } + + /** + * Returns the first non-null {@link DrmInitData} found in the content's tracks, or null if no + * {@link DrmInitData} are found. + */ + @Nullable + private DrmInitData findDrmInitData(DownloadHelper helper) { + for (int periodIndex = 0; periodIndex < helper.getPeriodCount(); periodIndex++) { + MappedTrackInfo mappedTrackInfo = helper.getMappedTrackInfo(periodIndex); + for (int rendererIndex = 0; + rendererIndex < mappedTrackInfo.getRendererCount(); + rendererIndex++) { + TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); + for (int trackGroupIndex = 0; trackGroupIndex < trackGroups.length; trackGroupIndex++) { + TrackGroup trackGroup = trackGroups.get(trackGroupIndex); + for (int formatIndex = 0; formatIndex < trackGroup.length; formatIndex++) { + Format format = trackGroup.getFormat(formatIndex); + if (format.drmInitData != null) { + return format.drmInitData; + } + } + } + } + } + return null; + } } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 9943cbe605..20a871b922 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -112,7 +112,7 @@ public class PlayerActivity extends AppCompatActivity public void onCreate(Bundle savedInstanceState) { Intent intent = getIntent(); super.onCreate(savedInstanceState); - dataSourceFactory = buildDataSourceFactory(); + dataSourceFactory = DemoUtil.getDataSourceFactory(/* context= */ this); if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) { CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER); } @@ -405,11 +405,6 @@ public class PlayerActivity extends AppCompatActivity startPosition = C.TIME_UNSET; } - /** Returns a new DataSource factory. */ - protected DataSource.Factory buildDataSourceFactory() { - return DemoUtil.buildDataSourceFactory(/* context= */ this); - } - // User controls private void updateButtonVisibility() { diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java index 04e176d0ab..d373ace8b6 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java @@ -253,9 +253,6 @@ public class SampleChooserActivity extends AppCompatActivity } MediaItem.PlaybackProperties playbackProperties = checkNotNull(playlistHolder.mediaItems.get(0).playbackProperties); - if (playbackProperties.drmConfiguration != null) { - return R.string.download_drm_unsupported; - } if (((IntentUtil.Tag) checkNotNull(playbackProperties.tag)).isLive) { return R.string.download_live_unsupported; } diff --git a/demos/main/src/main/res/values/strings.xml b/demos/main/src/main/res/values/strings.xml index 2cacaf5c37..2b3588754f 100644 --- a/demos/main/src/main/res/values/strings.xml +++ b/demos/main/src/main/res/values/strings.xml @@ -29,7 +29,7 @@ Unrecognized stereo mode - Protected content not supported on API levels below 18 + DRM content not supported on API levels below 18 This device does not support the required DRM scheme @@ -55,9 +55,9 @@ Failed to start download - This demo app does not support downloading playlists + Failed to obtain offline license - This demo app does not support downloading protected content + This demo app does not support downloading playlists This demo app only supports downloading http streams diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index 909a56e8f6..8cb619f2a2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -743,13 +743,17 @@ public final class DownloadHelper { * @return The built {@link DownloadRequest}. */ public DownloadRequest getDownloadRequest(String id, @Nullable byte[] data) { + DownloadRequest.Builder requestBuilder = + new DownloadRequest.Builder(id, playbackProperties.uri) + .setMimeType(playbackProperties.mimeType) + .setKeySetId( + playbackProperties.drmConfiguration != null + ? playbackProperties.drmConfiguration.getKeySetId() + : null) + .setCustomCacheKey(playbackProperties.customCacheKey) + .setData(data); if (mediaSource == null) { - // TODO: add support for DRM (keySetId) [Internal ref: b/158980798] - return new DownloadRequest.Builder(id, playbackProperties.uri) - .setMimeType(playbackProperties.mimeType) - .setCustomCacheKey(playbackProperties.customCacheKey) - .setData(data) - .build(); + return requestBuilder.build(); } assertPreparedWithMedia(); List streamKeys = new ArrayList<>(); @@ -763,13 +767,7 @@ public final class DownloadHelper { } streamKeys.addAll(mediaPreparer.mediaPeriods[periodIndex].getStreamKeys(allSelections)); } - // TODO: add support for DRM (keySetId) [Internal ref: b/158980798] - return new DownloadRequest.Builder(id, playbackProperties.uri) - .setMimeType(playbackProperties.mimeType) - .setStreamKeys(streamKeys) - .setCustomCacheKey(playbackProperties.customCacheKey) - .setData(data) - .build(); + return requestBuilder.setStreamKeys(streamKeys).build(); } // Initialization of array of Lists. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java index 60c2289f2b..31d86349ce 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java @@ -174,6 +174,16 @@ public final class DownloadRequest implements Parcelable { return new DownloadRequest(id, uri, mimeType, streamKeys, keySetId, customCacheKey, data); } + /** + * Returns a copy with the specified key set ID. + * + * @param keySetId The key set ID of the copy. + * @return The copy with the specified key set ID. + */ + public DownloadRequest copyWithKeySetId(@Nullable byte[] keySetId) { + return new DownloadRequest(id, uri, mimeType, streamKeys, keySetId, customCacheKey, data); + } + /** * Returns the result of merging {@code newRequest} into this request. The requests must have the * same {@link #id}.