diff --git a/RELEASENOTES.md b/RELEASENOTES.md index d69c6bac2a0..83cfcba8591 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -148,6 +148,9 @@ information. This is a source breaking change, but breakages can be easily resolved by wrapping the previous `byte[]` return value with `new Response` before returning. + * Add key request info like URL and latency to + `AnalyticsListener.onDrmKeysLoaded` + ([#1001](https://github.com/androidx/media/issues/1001)). * Effect: * Muxers: * Add `MediaMuxerCompat`, a drop-in replacement for framework diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java index bbc879f2911..aac72609180 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java @@ -35,6 +35,7 @@ import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.drm.DrmSession; import androidx.media3.exoplayer.drm.DrmSessionEventListener; +import androidx.media3.exoplayer.drm.KeyRequestInfo; import androidx.media3.exoplayer.source.LoadEventInfo; import androidx.media3.exoplayer.source.MaskingMediaPeriod; import androidx.media3.exoplayer.source.MaskingMediaSource; @@ -692,13 +693,17 @@ public void onDrmSessionAcquired( @Override public void onDrmKeysLoaded( - int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { + int windowIndex, + @Nullable MediaSource.MediaPeriodId mediaPeriodId, + KeyRequestInfo keyRequestInfo) { @Nullable Pair eventParameters = getEventParameters(windowIndex, mediaPeriodId); if (eventParameters != null) { eventHandler.post( - () -> eventListener.onDrmKeysLoaded(eventParameters.first, eventParameters.second)); + () -> + eventListener.onDrmKeysLoaded( + eventParameters.first, eventParameters.second, keyRequestInfo)); } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/AnalyticsListener.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/AnalyticsListener.java index 7a52f475f6e..cd3a2372fab 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/AnalyticsListener.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/AnalyticsListener.java @@ -57,6 +57,7 @@ import androidx.media3.exoplayer.DecoderReuseEvaluation; import androidx.media3.exoplayer.audio.AudioSink; import androidx.media3.exoplayer.drm.DrmSession; +import androidx.media3.exoplayer.drm.KeyRequestInfo; import androidx.media3.exoplayer.metadata.MetadataOutput; import androidx.media3.exoplayer.source.LoadEventInfo; import androidx.media3.exoplayer.source.MediaLoadData; @@ -1363,13 +1364,21 @@ default void onDrmSessionAcquired(EventTime eventTime) {} @UnstableApi default void onDrmSessionAcquired(EventTime eventTime, @DrmSession.State int state) {} + /** + * @deprecated Implement {@link #onDrmKeysLoaded(EventTime, KeyRequestInfo)} instead. + */ + @UnstableApi + @Deprecated + default void onDrmKeysLoaded(EventTime eventTime) {} + /** * Called each time drm keys are loaded. * * @param eventTime The event time. + * @param keyRequestInfo information for any required load operation, null if none */ @UnstableApi - default void onDrmKeysLoaded(EventTime eventTime) {} + default void onDrmKeysLoaded(EventTime eventTime, KeyRequestInfo keyRequestInfo) {} /** * Called when a drm error occurs. diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollector.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollector.java index c46d0f99604..bf937fe0bca 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollector.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollector.java @@ -53,6 +53,7 @@ import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime; import androidx.media3.exoplayer.audio.AudioSink; import androidx.media3.exoplayer.drm.DrmSession; +import androidx.media3.exoplayer.drm.KeyRequestInfo; import androidx.media3.exoplayer.source.LoadEventInfo; import androidx.media3.exoplayer.source.MediaLoadData; import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; @@ -852,12 +853,17 @@ public final void onDrmSessionAcquired( } @Override - public final void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + @SuppressWarnings("deprecation") // Calls deprecated listener method. + public void onDrmKeysLoaded( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, KeyRequestInfo keyRequestInfo) { EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); sendEvent( eventTime, AnalyticsListener.EVENT_DRM_KEYS_LOADED, - listener -> listener.onDrmKeysLoaded(eventTime)); + listener -> { + listener.onDrmKeysLoaded(eventTime); + listener.onDrmKeysLoaded(eventTime, keyRequestInfo); + }); } @Override diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DefaultDrmSession.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DefaultDrmSession.java index 5c612392bff..ed2c33b5d86 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DefaultDrmSession.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DefaultDrmSession.java @@ -137,6 +137,7 @@ public interface ReferenceCountListener { private final UUID uuid; private final Looper playbackLooper; private final ResponseHandler responseHandler; + private final Object keyRequestInfoLock; private @DrmSession.State int state; private int referenceCount; @@ -148,6 +149,11 @@ public interface ReferenceCountListener { private byte @MonotonicNonNull [] offlineLicenseKeySetId; @Nullable private KeyRequest currentKeyRequest; + + @GuardedBy("keyRequestInfoLock") + @Nullable + private KeyRequestInfo.Builder currentKeyRequestInfo; + @Nullable private ProvisionRequest currentProvisionRequest; /** @@ -209,6 +215,7 @@ public DefaultDrmSession( state = STATE_OPENING; this.playbackLooper = playbackLooper; responseHandler = new ResponseHandler(playbackLooper); + keyRequestInfoLock = new Object(); } public boolean hasSessionId(byte[] sessionId) { @@ -348,6 +355,9 @@ public void release(@Nullable DrmSessionEventListener.EventDispatcher eventDispa cryptoConfig = null; lastException = null; currentKeyRequest = null; + synchronized (keyRequestInfoLock) { + currentKeyRequestInfo = null; + } currentProvisionRequest = null; if (sessionId != null) { mediaDrm.closeSession(sessionId); @@ -489,6 +499,12 @@ private long getLicenseDurationRemainingSec() { private void postKeyRequest(byte[] scope, int type, boolean allowRetry) { try { + synchronized (keyRequestInfoLock) { + currentKeyRequestInfo = new KeyRequestInfo.Builder(); + if (schemeDatas != null) { + currentKeyRequestInfo.setSchemeDatas(schemeDatas); + } + } currentKeyRequest = mediaDrm.getKeyRequest(scope, schemeDatas, type, keyRequestParameters); Util.castNonNull(requestHandler).post(MSG_KEYS, checkNotNull(currentKeyRequest), allowRetry); } catch (Exception | NoSuchMethodError e) { @@ -502,6 +518,13 @@ private void onKeyResponse(Object request, Object response) { return; } currentKeyRequest = null; + KeyRequestInfo keyRequestInfo; + synchronized (keyRequestInfoLock) { + // currentKeyRequest and currentKeyRequestInfo are assigned together, and nulled-out together, + // so it must be non-null here. + keyRequestInfo = checkNotNull(currentKeyRequestInfo).build(); + currentKeyRequestInfo = null; + } if (response instanceof Exception || response instanceof NoSuchMethodError) { onKeysError((Throwable) response, /* thrownByExoMediaDrm= */ false); @@ -510,8 +533,11 @@ private void onKeyResponse(Object request, Object response) { try { byte[] responseData = ((MediaDrmCallback.Response) response).data; + if (mode == DefaultDrmSessionManager.MODE_RELEASE) { mediaDrm.provideKeyResponse(Util.castNonNull(offlineLicenseKeySetId), responseData); + // TODO: http://github.com/androidx/media/issues/1001 - Plumb the KeyLoadInfo up into + // drmKeysRemoved. dispatchEvent(DrmSessionEventListener.EventDispatcher::drmKeysRemoved); } else { byte[] keySetId = mediaDrm.provideKeyResponse(sessionId, responseData); @@ -523,7 +549,7 @@ private void onKeyResponse(Object request, Object response) { offlineLicenseKeySetId = keySetId; } state = STATE_OPENED_WITH_KEYS; - dispatchEvent(DrmSessionEventListener.EventDispatcher::drmKeysLoaded); + dispatchEvent(eventDispatcher -> eventDispatcher.drmKeysLoaded(keyRequestInfo)); } } catch (Exception | NoSuchMethodError e) { onKeysError(e, /* thrownByExoMediaDrm= */ true); @@ -659,7 +685,17 @@ public void handleMessage(Message msg) { callback.executeProvisionRequest(uuid, (ProvisionRequest) requestTask.request); break; case MSG_KEYS: - response = callback.executeKeyRequest(uuid, (KeyRequest) requestTask.request); + MediaDrmCallback.Response keyResponse = + callback.executeKeyRequest(uuid, (KeyRequest) requestTask.request); + response = keyResponse; + synchronized (keyRequestInfoLock) { + if (currentKeyRequestInfo != null && keyResponse.loadEventInfo != null) { + currentKeyRequestInfo.addLoadInfo( + keyResponse.loadEventInfo.copyWithTaskIdAndDurationMs( + requestTask.taskId, + SystemClock.elapsedRealtime() - requestTask.startTimeMs)); + } + } break; default: throw new RuntimeException(); @@ -715,6 +751,11 @@ private boolean maybeRetryRequest(Message originalMsg, MediaDrmCallbackException // The error is fatal. return false; } + synchronized (keyRequestInfoLock) { + if (currentKeyRequestInfo != null) { + currentKeyRequestInfo.addLoadInfo(loadEventInfo); + } + } synchronized (this) { if (!isReleased) { sendMessageDelayed(Message.obtain(originalMsg), retryDelayMs); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DrmSessionEventListener.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DrmSessionEventListener.java index 6b17c95d23b..ed149af04d0 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DrmSessionEventListener.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DrmSessionEventListener.java @@ -40,13 +40,21 @@ public interface DrmSessionEventListener { default void onDrmSessionAcquired( int windowIndex, @Nullable MediaPeriodId mediaPeriodId, @DrmSession.State int state) {} + /** + * @deprecated Implement {@link #onDrmKeysLoaded(int, MediaPeriodId, KeyRequestInfo)} instead + */ + @Deprecated + default void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {} + /** * Called each time keys are loaded. * * @param windowIndex The window index in the timeline this media period belongs to. * @param mediaPeriodId The {@link MediaPeriodId} associated with the drm session. + * @param keyRequestInfo The {@link KeyRequestInfo} with load info for the drm server requests */ - default void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {} + default void onDrmKeysLoaded( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, KeyRequestInfo keyRequestInfo) {} /** * Called when a drm error occurs. @@ -166,12 +174,19 @@ public void drmSessionAcquired(@DrmSession.State int state) { } } - /** Dispatches {@link #onDrmKeysLoaded(int, MediaPeriodId)}. */ - public void drmKeysLoaded() { + /** + * Dispatches {@link #onDrmKeysLoaded(int, MediaPeriodId)}. and {@link #onDrmKeysLoaded(int, + * MediaPeriodId, KeyRequestInfo)} + */ + public void drmKeysLoaded(KeyRequestInfo keyRequestInfo) { for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { DrmSessionEventListener listener = listenerAndHandler.listener; postOrRun( - listenerAndHandler.handler, () -> listener.onDrmKeysLoaded(windowIndex, mediaPeriodId)); + listenerAndHandler.handler, + () -> { + listener.onDrmKeysLoaded(windowIndex, mediaPeriodId); + listener.onDrmKeysLoaded(windowIndex, mediaPeriodId, keyRequestInfo); + }); } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DrmUtil.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DrmUtil.java index 47c85e70e44..3b3430b013f 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DrmUtil.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DrmUtil.java @@ -27,6 +27,7 @@ import android.media.MediaDrmResetException; import android.media.NotProvisionedException; import android.media.ResourceBusyException; +import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.PlaybackException; @@ -37,6 +38,7 @@ import androidx.media3.datasource.DataSpec; import androidx.media3.datasource.HttpDataSource; import androidx.media3.datasource.StatsDataSource; +import androidx.media3.exoplayer.source.LoadEventInfo; import com.google.common.io.ByteStreams; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -145,11 +147,21 @@ public static boolean isFailureToConstructResourceBusyException(@Nullable Throwa * *

Note that this method is executing the request synchronously and blocks until finished. * + *

The {@link LoadEventInfo} returned inside the {@link MediaDrmCallback.Response} will have + * the following fields unset, and they must be updated by caller before the {@link LoadEventInfo} + * is used elsewhere: + * + *

+ * * @param dataSource A {@link DataSource}. * @param url The requested URL. * @param httpBody The HTTP request payload. * @param requestProperties A keyed map of HTTP header request properties. - * @return A byte array that holds the response payload. + * @return A {@link MediaDrmCallback.Response} that holds the response payload, and {@link + * LoadEventInfo}. * @throws MediaDrmCallbackException if an exception was encountered during the download. */ public static MediaDrmCallback.Response executePost( @@ -173,7 +185,19 @@ public static MediaDrmCallback.Response executePost( while (true) { DataSourceInputStream inputStream = new DataSourceInputStream(statsDataSource, dataSpec); try { - return new MediaDrmCallback.Response(ByteStreams.toByteArray(inputStream)); + byte[] response = ByteStreams.toByteArray(inputStream); + LoadEventInfo loadEventInfo = + new LoadEventInfo( + -1, // This will be replaced with the actual taskId from the request. + originalDataSpec, + statsDataSource.getLastOpenedUri(), + statsDataSource.getLastResponseHeaders(), + SystemClock.elapsedRealtime(), + /* loadDurationMs= */ 0, + response.length); + return new MediaDrmCallback.Response.Builder(response) + .setLoadEventInfo(loadEventInfo) + .build(); } catch (HttpDataSource.InvalidResponseCodeException e) { @Nullable String redirectUrl = getRedirectUrl(e, manualRedirectCount); if (redirectUrl == null) { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/KeyRequestInfo.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/KeyRequestInfo.java new file mode 100644 index 00000000000..420529487e0 --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/KeyRequestInfo.java @@ -0,0 +1,88 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.drm; + +import androidx.annotation.Nullable; +import androidx.media3.common.DrmInitData.SchemeData; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.source.LoadEventInfo; +import com.google.common.collect.ImmutableList; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.List; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.dataflow.qual.SideEffectFree; + +/** Information related to a completed DRM key request operation. */ +// TODO: http://github.com/androidx/media/issues/1001 - Add sessionId field. +@UnstableApi +public final class KeyRequestInfo { + + /** Builder for {@link KeyRequestInfo} instances. */ + public static final class Builder { + private final ImmutableList.Builder loadEventInfos; + private @MonotonicNonNull ImmutableList schemeDatas; + + /** Constructs an instance. */ + public Builder() { + loadEventInfos = ImmutableList.builder(); + } + + /** Sets the {@link SchemeData} instances associated with the key request. */ + @CanIgnoreReturnValue + public Builder setSchemeDatas(List schemeDatas) { + this.schemeDatas = ImmutableList.copyOf(schemeDatas); + return this; + } + + /** + * Adds info for a load associated with this key request. May be called again to add info for + * any retry requests. + */ + @CanIgnoreReturnValue + public Builder addLoadInfo(LoadEventInfo loadEventInfo) { + this.loadEventInfos.add(loadEventInfo); + return this; + } + + /** Builds a {@link KeyRequestInfo} instance. */ + @SideEffectFree + public KeyRequestInfo build() { + return new KeyRequestInfo(this); + } + } + + /** + * The {@link LoadEventInfo} instances for the requests used to load the key. + * + *

This list will be empty if the {@link MediaDrmCallback} used to serve the request doesn't + * populate {@link MediaDrmCallback.Response#loadEventInfo}. + * + *

Entries in this list are in ascending order by timestamp with the first request first in the + * list, followed by entries for any retries needed to load the key. + */ + public final ImmutableList loadInfos; + + /** + * The DRM {@link SchemeData} that identifies the loaded key, or null if this session uses offline + * keys. + */ + @Nullable public final ImmutableList schemeDatas; + + private KeyRequestInfo(Builder builder) { + loadInfos = builder.loadEventInfos.build(); + schemeDatas = builder.schemeDatas; + } +} diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/MediaDrmCallback.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/MediaDrmCallback.java index f6d2579d00f..6f2e063cd9b 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/MediaDrmCallback.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/MediaDrmCallback.java @@ -15,9 +15,12 @@ */ package androidx.media3.exoplayer.drm; +import androidx.annotation.Nullable; import androidx.media3.common.util.UnstableApi; import androidx.media3.exoplayer.drm.ExoMediaDrm.KeyRequest; import androidx.media3.exoplayer.drm.ExoMediaDrm.ProvisionRequest; +import androidx.media3.exoplayer.source.LoadEventInfo; +import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.util.UUID; /** Performs {@link ExoMediaDrm} key and provisioning requests. */ @@ -25,20 +28,63 @@ public interface MediaDrmCallback { /** Response data from the {@link MediaDrmCallback} requests. */ - final class Response { + public final class Response { + + /** Builder for {@link Response} instances. */ + public static final class Builder { + + private final byte[] data; + + @Nullable private LoadEventInfo loadEventInfo; + + /** Constructs an instance. */ + public Builder(byte[] data) { + this.data = data; + } + + /** Sets the optional {@link LoadEventInfo} associated with this response. */ + @CanIgnoreReturnValue + public Builder setLoadEventInfo(LoadEventInfo loadEventInfo) { + this.loadEventInfo = loadEventInfo; + return this; + } + + /** Builds the response. */ + public Response build() { + return new Response(this); + } + } /** The response from the license or provisioning server. */ public final byte[] data; + /** The optional load info associated with this response. */ + @Nullable public final LoadEventInfo loadEventInfo; + /** Constructs an instance. */ public Response(byte[] data) { this.data = data; + this.loadEventInfo = null; + } + + private Response(Builder builder) { + this.data = builder.data; + this.loadEventInfo = builder.loadEventInfo; } } /** * Executes a provisioning request. * + *

The {@link LoadEventInfo} returned inside the {@link Response} will have the following + * fields unset, and they must be updated by caller before the {@link LoadEventInfo} is used + * elsewhere: + * + *

    + *
  • {@link LoadEventInfo#loadTaskId} + *
  • {@link LoadEventInfo#loadDurationMs} + *
+ * * @param uuid The UUID of the content protection scheme. * @param request The request. * @return The response data. @@ -50,6 +96,15 @@ Response executeProvisionRequest(UUID uuid, ProvisionRequest request) /** * Executes a key request. * + *

The {@link LoadEventInfo} returned inside the {@link Response} will have the following + * fields unset, and they must be updated by caller before the {@link LoadEventInfo} is used + * elsewhere: + * + *

    + *
  • {@link LoadEventInfo#loadTaskId} + *
  • {@link LoadEventInfo#loadDurationMs} + *
+ * * @param uuid The UUID of the content protection scheme. * @param request The request. * @return The response data. diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/OfflineLicenseHelper.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/OfflineLicenseHelper.java index 09d53b156e8..dabe0cc7f1b 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/OfflineLicenseHelper.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/OfflineLicenseHelper.java @@ -195,7 +195,8 @@ public OfflineLicenseHelper( DrmSessionEventListener eventListener = new DrmSessionEventListener() { @Override - public void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + public void onDrmKeysLoaded( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, KeyRequestInfo keyLoadInfo) { drmListenerConditionVariable.open(); } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/CompositeMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/CompositeMediaSource.java index 999e888c1c5..0acaa3b1f56 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/CompositeMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/CompositeMediaSource.java @@ -28,6 +28,7 @@ import androidx.media3.datasource.TransferListener; import androidx.media3.exoplayer.drm.DrmSession; import androidx.media3.exoplayer.drm.DrmSessionEventListener; +import androidx.media3.exoplayer.drm.KeyRequestInfo; import java.io.IOException; import java.util.HashMap; import java.util.Objects; @@ -320,9 +321,10 @@ public void onDrmSessionAcquired( } @Override - public void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + public void onDrmKeysLoaded( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, KeyRequestInfo keyRequestInfo) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - drmEventDispatcher.drmKeysLoaded(); + drmEventDispatcher.drmKeysLoaded(keyRequestInfo); } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/LoadEventInfo.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/LoadEventInfo.java index cf2ca7635f5..0cd0f7e12a7 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/LoadEventInfo.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/LoadEventInfo.java @@ -104,4 +104,9 @@ public LoadEventInfo( this.loadDurationMs = loadDurationMs; this.bytesLoaded = bytesLoaded; } + + public LoadEventInfo copyWithTaskIdAndDurationMs(long loadTaskId, long loadDurationMs) { + return new LoadEventInfo( + loadTaskId, dataSpec, uri, responseHeaders, elapsedRealtimeMs, loadDurationMs, bytesLoaded); + } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/ServerSideAdInsertionMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/ServerSideAdInsertionMediaSource.java index 84c57a4c1cf..fae628e7f48 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/ServerSideAdInsertionMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/ServerSideAdInsertionMediaSource.java @@ -46,6 +46,7 @@ import androidx.media3.exoplayer.SeekParameters; import androidx.media3.exoplayer.drm.DrmSession; import androidx.media3.exoplayer.drm.DrmSessionEventListener; +import androidx.media3.exoplayer.drm.KeyRequestInfo; import androidx.media3.exoplayer.source.BaseMediaSource; import androidx.media3.exoplayer.source.EmptySampleStream; import androidx.media3.exoplayer.source.ForwardingTimeline; @@ -358,15 +359,16 @@ public void onDrmSessionAcquired( } @Override - public void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + public void onDrmKeysLoaded( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, KeyRequestInfo keyRequestInfo) { @Nullable MediaPeriodImpl mediaPeriod = getMediaPeriodForEvent( mediaPeriodId, /* mediaLoadData= */ null, /* useLoadingPeriod= */ false); if (mediaPeriod == null) { - drmEventDispatcherWithoutId.drmKeysLoaded(); + drmEventDispatcherWithoutId.drmKeysLoaded(keyRequestInfo); } else { - mediaPeriod.drmEventDispatcher.drmKeysLoaded(); + mediaPeriod.drmEventDispatcher.drmKeysLoaded(keyRequestInfo); } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/util/EventLogger.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/util/EventLogger.java index fc58e506147..e447b044ca5 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/util/EventLogger.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/util/EventLogger.java @@ -41,6 +41,7 @@ import androidx.media3.exoplayer.analytics.AnalyticsListener; import androidx.media3.exoplayer.audio.AudioSink; import androidx.media3.exoplayer.drm.DrmSession; +import androidx.media3.exoplayer.drm.KeyRequestInfo; import androidx.media3.exoplayer.source.LoadEventInfo; import androidx.media3.exoplayer.source.MediaLoadData; import androidx.media3.exoplayer.trackselection.MappingTrackSelector; @@ -516,7 +517,7 @@ public void onDrmKeysRemoved(EventTime eventTime) { @UnstableApi @Override - public void onDrmKeysLoaded(EventTime eventTime) { + public void onDrmKeysLoaded(EventTime eventTime, KeyRequestInfo keyRequestInfo) { logd(eventTime, "drmKeysLoaded"); } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/drm/DefaultDrmSessionManagerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/drm/DefaultDrmSessionManagerTest.java index 9eb300497e2..0efe7d49ea6 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/drm/DefaultDrmSessionManagerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/drm/DefaultDrmSessionManagerTest.java @@ -16,9 +16,15 @@ package androidx.media3.exoplayer.drm; import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.collect.MoreCollectors.onlyElement; import static com.google.common.truth.Truth.assertThat; import static java.util.concurrent.TimeUnit.SECONDS; import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import android.os.Looper; import androidx.annotation.Nullable; @@ -37,11 +43,14 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import java.util.UUID; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowLooper; +import org.robolectric.shadows.ShadowSystemClock; /** Tests for {@link DefaultDrmSessionManager} and {@link DefaultDrmSession}. */ // TODO: Test more branches: @@ -82,6 +91,96 @@ public void acquireSession_triggersKeyLoadAndSessionIsOpened() throws Exception .containsExactly(FakeExoMediaDrm.KEY_STATUS_KEY, FakeExoMediaDrm.KEY_STATUS_AVAILABLE); } + @Test(timeout = 10_000) + public void acquireSession_retries_reportsMultipleLoadEvents() { + FakeExoMediaDrm.LicenseServer licenseServer = + new FakeExoMediaDrm.LicenseServer.Builder() + .addAllowedSchemeDatas(DRM_SCHEME_DATAS) + .setFailedRequestCount(1) + .build(); + MediaDrmCallback clockAdvancingLicenseServer = + new MediaDrmCallback() { + private long requestDurationMs = 100; + + @Override + public Response executeProvisionRequest(UUID uuid, ExoMediaDrm.ProvisionRequest request) + throws MediaDrmCallbackException { + ShadowSystemClock.advanceBy(requestDurationMs, TimeUnit.MILLISECONDS); + requestDurationMs += 100; + return licenseServer.executeProvisionRequest(uuid, request); + } + + @Override + public Response executeKeyRequest(UUID uuid, ExoMediaDrm.KeyRequest request) + throws MediaDrmCallbackException { + ShadowSystemClock.advanceBy(requestDurationMs, TimeUnit.MILLISECONDS); + requestDurationMs += 100; + return licenseServer.executeKeyRequest(uuid, request); + } + }; + + DrmSessionEventListener.EventDispatcher eventDispatcher = + new DrmSessionEventListener.EventDispatcher(); + DrmSessionEventListener drmSessionEventListener = mock(DrmSessionEventListener.class); + eventDispatcher.addEventListener(Util.createHandlerForCurrentLooper(), drmSessionEventListener); + + DefaultDrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider( + DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm.Builder().build()) + .build(/* mediaDrmCallback= */ clockAdvancingLicenseServer); + drmSessionManager.setPlayer(/* playbackLooper= */ Looper.myLooper(), PlayerId.UNSET); + drmSessionManager.prepare(); + DrmSession drmSession = + checkNotNull( + drmSessionManager.acquireSession( + /* eventDispatcher= */ eventDispatcher, FORMAT_WITH_DRM_INIT_DATA)); + + ArgumentCaptor keyRequestInfoCaptor = + ArgumentCaptor.forClass(KeyRequestInfo.class); + // Wait for the key load event to propagate + while (keyRequestInfoCaptor.getAllValues().isEmpty()) { + ShadowLooper.idleMainLooper(); + verify(drmSessionEventListener, atLeast(0)) + .onDrmKeysLoaded( + /* windowIndex= */ anyInt(), + /* mediaPeriodId= */ any(), + keyRequestInfoCaptor.capture()); + } + + assertThat(keyRequestInfoCaptor.getValue().loadInfos).hasSize(2); + assertThat(keyRequestInfoCaptor.getValue().loadInfos.get(0).bytesLoaded).isEqualTo(0); + assertThat(keyRequestInfoCaptor.getValue().loadInfos.get(0).loadDurationMs).isEqualTo(100); + assertThat(keyRequestInfoCaptor.getValue().loadInfos.get(1).bytesLoaded).isGreaterThan(0); + // First request takes 100ms, and the retry 200ms, so 300ms in total. + assertThat(keyRequestInfoCaptor.getValue().loadInfos.get(1).loadDurationMs).isEqualTo(300); + + // Assert the retry is 'immediate' + assertThat( + keyRequestInfoCaptor.getValue().loadInfos.get(0).elapsedRealtimeMs + - keyRequestInfoCaptor.getValue().loadInfos.get(0).loadDurationMs) + .isEqualTo( + keyRequestInfoCaptor.getValue().loadInfos.get(1).elapsedRealtimeMs + - keyRequestInfoCaptor.getValue().loadInfos.get(1).loadDurationMs); + + // Assert that the load task IDs and URIs are the same for each retry. + assertThat( + keyRequestInfoCaptor.getValue().loadInfos.stream() + .map(eventInfo -> eventInfo.loadTaskId) + .distinct() + .collect(onlyElement())) + .isAtLeast(0); + assertThat( + keyRequestInfoCaptor.getValue().loadInfos.stream() + .map(eventInfo -> eventInfo.dataSpec.uri) + .distinct() + .collect(onlyElement())) + .isEqualTo(FakeExoMediaDrm.LICENSE_SERVER_URI); + + drmSession.release(eventDispatcher); + drmSessionManager.release(); + } + @Test(timeout = 10_000) public void keepaliveEnabled_sessionsKeptForRequestedTime() throws Exception { FakeExoMediaDrm.LicenseServer licenseServer = @@ -436,7 +535,9 @@ public void preacquireSession_loadsKeysBeforeFullAcquisition() throws Exception new DrmSessionEventListener() { @Override public void onDrmKeysLoaded( - int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { + int windowIndex, + @Nullable MediaSource.MediaPeriodId mediaPeriodId, + KeyRequestInfo keyRequestInfo) { keyLoadCount.incrementAndGet(); } }); @@ -811,8 +912,10 @@ public void keyResponseIndicatesProvisioningRequired_provisioningDone_noSuchMeth private static void keyResponseIndicatesProvisioningRequiredProvisioningDone( boolean throwNoSuchMethodErrorForNotProvisioned) { FakeExoMediaDrm.LicenseServer licenseServer = - FakeExoMediaDrm.LicenseServer.requiringProvisioningThenAllowingSchemeDatas( - DRM_SCHEME_DATAS); + new FakeExoMediaDrm.LicenseServer.Builder() + .addAllowedSchemeDatas(DRM_SCHEME_DATAS) + .setRequiresProvisioning(true) + .build(); DefaultDrmSessionManager drmSessionManager = new DefaultDrmSessionManager.Builder() diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/util/EventLoggerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/util/EventLoggerTest.java index dc06dbdec3f..144a0f63bac 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/util/EventLoggerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/util/EventLoggerTest.java @@ -36,6 +36,7 @@ import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime; import androidx.media3.exoplayer.audio.AudioSink.AudioTrackConfig; import androidx.media3.exoplayer.drm.DrmSession; +import androidx.media3.exoplayer.drm.KeyRequestInfo; import androidx.media3.exoplayer.source.LoadEventInfo; import androidx.media3.exoplayer.source.MediaLoadData; import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; @@ -540,7 +541,7 @@ public void onDrmKeysRemoved() { @Test public void onDrmKeysLoaded() { - eventLogger.onDrmKeysLoaded(EVENT_TIME); + eventLogger.onDrmKeysLoaded(EVENT_TIME, new KeyRequestInfo.Builder().build()); assertThat(onlyLogMessage()) .isEqualTo("drmKeysLoaded [eventTime=0.02, mediaPos=0.46, window=0, period=0]"); } diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeExoMediaDrm.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeExoMediaDrm.java index 4a4da89d88f..b5ead853973 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeExoMediaDrm.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeExoMediaDrm.java @@ -26,19 +26,23 @@ import android.media.MediaDrmException; import android.media.NotProvisionedException; import android.media.ResourceBusyException; +import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; import android.os.PersistableBundle; +import android.os.SystemClock; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.media3.common.C; import androidx.media3.common.DrmInitData; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; +import androidx.media3.datasource.DataSpec; import androidx.media3.decoder.CryptoConfig; import androidx.media3.exoplayer.drm.ExoMediaDrm; import androidx.media3.exoplayer.drm.MediaDrmCallback; import androidx.media3.exoplayer.drm.MediaDrmCallbackException; +import androidx.media3.exoplayer.source.LoadEventInfo; import com.google.common.base.Predicate; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -178,6 +182,12 @@ public FakeExoMediaDrm build() { public static final ImmutableList VALID_PROVISION_RESPONSE = TestUtil.createByteList(4, 5, 6); + /** + * The license server URL used in the return value from {@link #getKeyRequest(byte[], List, int, + * HashMap)}. + */ + public static final Uri LICENSE_SERVER_URI = Uri.parse("foo://license-server.test"); + /** Key for use with the Map returned from {@link FakeExoMediaDrm#queryKeyStatus(byte[])}. */ public static final String KEY_STATUS_KEY = "KEY_STATUS"; @@ -328,7 +338,7 @@ public KeyRequest getKeyRequest( sessionIdsWithValidKeys.contains(toByteList(scope)) ? KeyRequest.REQUEST_TYPE_RENEWAL : KeyRequest.REQUEST_TYPE_INITIAL; - return new KeyRequest(requestData.toByteArray(), /* licenseServerUrl= */ "", requestType); + return new KeyRequest(requestData.toByteArray(), LICENSE_SERVER_URI.toString(), requestType); } @Override @@ -502,38 +512,76 @@ private static ImmutableList toByteList(byte[] byteArray) { /** An license server implementation to interact with {@link FakeExoMediaDrm}. */ public static class LicenseServer implements MediaDrmCallback { + /** Builder for {@link LicenseServer} instances. */ + public static final class Builder { + + private final ImmutableSet.Builder> allowedSchemeDatas; + + private boolean requiresProvisioning; + private int failedRequestCount; + + public Builder() { + allowedSchemeDatas = ImmutableSet.builder(); + } + + @CanIgnoreReturnValue + public Builder addAllowedSchemeDatas(List schemeDatas) { + allowedSchemeDatas.add(ImmutableList.copyOf(schemeDatas)); + return this; + } + + @CanIgnoreReturnValue + public Builder setRequiresProvisioning(boolean requiresProvisioning) { + this.requiresProvisioning = requiresProvisioning; + return this; + } + + @CanIgnoreReturnValue + public Builder setFailedRequestCount(int failedRequestCount) { + this.failedRequestCount = failedRequestCount; + return this; + } + + public LicenseServer build() { + return new LicenseServer(this); + } + } + private final ImmutableSet> allowedSchemeDatas; private final List> receivedProvisionRequests; private final List> receivedSchemeDatas; private boolean nextResponseIndicatesProvisioningRequired; + private int remainingFailedRequestCount; @SafeVarargs public static LicenseServer allowingSchemeDatas(List... schemeDatas) { - ImmutableSet.Builder> schemeDatasBuilder = - ImmutableSet.builder(); + Builder licenseServer = new Builder(); for (List schemeData : schemeDatas) { - schemeDatasBuilder.add(ImmutableList.copyOf(schemeData)); + licenseServer.addAllowedSchemeDatas(schemeData); } - return new LicenseServer(schemeDatasBuilder.build()); + return licenseServer.build(); } + /** + * @deprecated Use {link Builder} instead. + */ @SafeVarargs + @Deprecated public static LicenseServer requiringProvisioningThenAllowingSchemeDatas( List... schemeDatas) { - ImmutableSet.Builder> schemeDatasBuilder = - ImmutableSet.builder(); + Builder licenseServer = new Builder(); for (List schemeData : schemeDatas) { - schemeDatasBuilder.add(ImmutableList.copyOf(schemeData)); + licenseServer.addAllowedSchemeDatas(schemeData); } - LicenseServer licenseServer = new LicenseServer(schemeDatasBuilder.build()); - licenseServer.nextResponseIndicatesProvisioningRequired = true; - return licenseServer; + return licenseServer.setRequiresProvisioning(true).build(); } - private LicenseServer(ImmutableSet> allowedSchemeDatas) { - this.allowedSchemeDatas = allowedSchemeDatas; + private LicenseServer(Builder builder) { + this.allowedSchemeDatas = builder.allowedSchemeDatas.build(); + nextResponseIndicatesProvisioningRequired = builder.requiresProvisioning; + remainingFailedRequestCount = builder.failedRequestCount; receivedProvisionRequests = new ArrayList<>(); receivedSchemeDatas = new ArrayList<>(); @@ -550,9 +598,21 @@ public ImmutableList> getReceivedSchemeDat @Override public Response executeProvisionRequest(UUID uuid, ProvisionRequest request) throws MediaDrmCallbackException { + checkFailedRequestCounter(request.getDefaultUrl()); receivedProvisionRequests.add(ImmutableList.copyOf(Bytes.asList(request.getData()))); + Uri uri = Uri.parse(request.getDefaultUrl()); if (Arrays.equals(request.getData(), FAKE_PROVISION_REQUEST.getData())) { - return new Response(Bytes.toArray(VALID_PROVISION_RESPONSE)); + return new Response.Builder(Bytes.toArray(VALID_PROVISION_RESPONSE)) + .setLoadEventInfo( + new LoadEventInfo( + /* loadTaskId= */ -1, + new DataSpec(uri), + uri, + /* responseHeaders= */ ImmutableMap.of(), + SystemClock.elapsedRealtime(), + /* loadDurationMs= */ 0, + /* bytesLoaded= */ VALID_PROVISION_RESPONSE.size())) + .build(); } else { return new Response(Util.EMPTY_BYTE_ARRAY); } @@ -561,6 +621,7 @@ public Response executeProvisionRequest(UUID uuid, ProvisionRequest request) @Override public Response executeKeyRequest(UUID uuid, KeyRequest request) throws MediaDrmCallbackException { + checkFailedRequestCounter(request.getLicenseServerUrl()); ImmutableList schemeDatas = KeyRequestData.fromByteArray(request.getData()).schemeDatas; receivedSchemeDatas.add(schemeDatas); @@ -574,7 +635,31 @@ public Response executeKeyRequest(UUID uuid, KeyRequest request) } else { response = KEY_DENIED_RESPONSE; } - return new Response(Bytes.toArray(response)); + Uri uri = Uri.parse(request.getLicenseServerUrl()); + return new Response.Builder(Bytes.toArray(response)) + .setLoadEventInfo( + new LoadEventInfo( + /* loadTaskId= */ -1, + new DataSpec(uri), + uri, + /* responseHeaders= */ ImmutableMap.of(), + SystemClock.elapsedRealtime(), + /* loadDurationMs= */ 0, + /* bytesLoaded= */ response.size())) + .build(); + } + + private void checkFailedRequestCounter(String url) throws MediaDrmCallbackException { + if (remainingFailedRequestCount > 0) { + remainingFailedRequestCount--; + Uri uri = Uri.parse(url); + throw new MediaDrmCallbackException( + new DataSpec(uri), + /* uriAfterRedirects= */ uri, + /* responseHeaders= */ ImmutableMap.of(), + /* bytesLoaded= */ 0, + new Exception()); + } } }