diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 4fe20d018d..62ccb5e2b7 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -144,6 +144,10 @@ `OfflineLicenseHelper` ([#7078](https://github.com/google/ExoPlayer/issues/7078)). * Remove generics from DRM components. + * Keep DRM sessions alive for a short time before fully releasing them + ([#7011](https://github.com/google/ExoPlayer/issues/7011), + [#6725](https://github.com/google/ExoPlayer/issues/6725), + [#7066](https://github.com/google/ExoPlayer/issues/7066)). * Downloads and caching: * Support passing an `Executor` to `DefaultDownloaderFactory` on which data downloads are performed. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java index ea7994868b..5a51638c17 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java @@ -85,15 +85,26 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; void onProvisionCompleted(); } - /** Callback to be notified when the session is released. */ - public interface ReleaseCallback { + /** Callback to be notified when the reference count of this session changes. */ + public interface ReferenceCountListener { /** - * Called immediately after releasing session resources. + * Called when the internal reference count of this session is incremented. * - * @param session The session. + * @param session This session. + * @param newReferenceCount The reference count after being incremented. */ - void onSessionReleased(DefaultDrmSession session); + void onReferenceCountIncremented(DefaultDrmSession session, int newReferenceCount); + + /** + * Called when the internal reference count of this session is decremented. + * + *

{@code newReferenceCount == 0} indicates this session is in {@link #STATE_RELEASED}. + * + * @param session This session. + * @param newReferenceCount The reference count after being decremented. + */ + void onReferenceCountDecremented(DefaultDrmSession session, int newReferenceCount); } private static final String TAG = "DefaultDrmSession"; @@ -107,7 +118,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final ExoMediaDrm mediaDrm; private final ProvisioningManager provisioningManager; - private final ReleaseCallback releaseCallback; + private final ReferenceCountListener referenceCountListener; private final @DefaultDrmSessionManager.Mode int mode; private final boolean playClearSamplesWithoutKeys; private final boolean isPlaceholderSession; @@ -137,7 +148,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; * @param uuid The UUID of the drm scheme. * @param mediaDrm The media DRM. * @param provisioningManager The manager for provisioning. - * @param releaseCallback The {@link ReleaseCallback}. + * @param referenceCountListener The {@link ReferenceCountListener}. * @param schemeDatas DRM scheme datas for this session, or null if an {@code * offlineLicenseKeySetId} is provided or if {@code isPlaceholderSession} is true. * @param mode The DRM mode. Ignored if {@code isPlaceholderSession} is true. @@ -154,7 +165,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; UUID uuid, ExoMediaDrm mediaDrm, ProvisioningManager provisioningManager, - ReleaseCallback releaseCallback, + ReferenceCountListener referenceCountListener, @Nullable List schemeDatas, @DefaultDrmSessionManager.Mode int mode, boolean playClearSamplesWithoutKeys, @@ -170,7 +181,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } this.uuid = uuid; this.provisioningManager = provisioningManager; - this.releaseCallback = releaseCallback; + this.referenceCountListener = referenceCountListener; this.mediaDrm = mediaDrm; this.mode = mode; this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; @@ -280,6 +291,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; eventDispatcher.dispatch( DrmSessionEventListener::onDrmSessionAcquired, DrmSessionEventListener.class); } + referenceCountListener.onReferenceCountIncremented(this, referenceCount); } @Override @@ -300,7 +312,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; mediaDrm.closeSession(sessionId); sessionId = null; } - releaseCallback.onSessionReleased(this); dispatchEvent(DrmSessionEventListener::onDrmSessionReleased); } if (eventDispatcher != null) { @@ -312,6 +323,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } eventDispatchers.remove(eventDispatcher); } + referenceCountListener.onReferenceCountDecremented(this, referenceCount); } // Internal methods. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java index 8335831ce0..2912af4c36 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java @@ -16,9 +16,11 @@ package com.google.android.exoplayer2.drm; import android.annotation.SuppressLint; +import android.media.ResourceBusyException; import android.os.Handler; import android.os.Looper; import android.os.Message; +import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -32,15 +34,18 @@ import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Sets; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** A {@link DrmSessionManager} that supports playbacks using {@link ExoMediaDrm}. */ @RequiresApi(18) @@ -60,6 +65,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { private int[] useDrmSessionsForClearContentTrackTypes; private boolean playClearSamplesWithoutKeys; private LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private long sessionKeepaliveMs; /** * Creates a builder with default values. The default values are: @@ -82,6 +88,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { exoMediaDrmProvider = FrameworkMediaDrm.DEFAULT_PROVIDER; loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); useDrmSessionsForClearContentTrackTypes = new int[0]; + sessionKeepaliveMs = DEFAULT_SESSION_KEEPALIVE_MS; } /** @@ -180,6 +187,27 @@ public class DefaultDrmSessionManager implements DrmSessionManager { return this; } + /** + * Sets the time to keep {@link DrmSession DrmSessions} alive when they're not in use. + * + *

It can be useful to keep sessions alive during playback of short clear sections of media + * (e.g. ad breaks) to avoid opening new DRM sessions (and re-requesting keys) at the transition + * back into secure content. This assumes the secure sections before and after the clear section + * are encrypted with the same keys. + * + *

Defaults to {@link #DEFAULT_SESSION_KEEPALIVE_MS}. Pass {@link C#TIME_UNSET} to disable + * keep-alive. + * + * @param sessionKeepaliveMs The time to keep {@link DrmSession}s alive before fully releasing, + * in milliseconds. Must be > 0 or {@link C#TIME_UNSET} to disable keep-alive. + * @return This builder. + */ + public Builder setSessionKeepaliveMs(long sessionKeepaliveMs) { + Assertions.checkArgument(sessionKeepaliveMs > 0 || sessionKeepaliveMs == C.TIME_UNSET); + this.sessionKeepaliveMs = sessionKeepaliveMs; + return this; + } + /** Builds a {@link DefaultDrmSessionManager} instance. */ public DefaultDrmSessionManager build(MediaDrmCallback mediaDrmCallback) { return new DefaultDrmSessionManager( @@ -190,7 +218,8 @@ public class DefaultDrmSessionManager implements DrmSessionManager { multiSession, useDrmSessionsForClearContentTrackTypes, playClearSamplesWithoutKeys, - loadErrorHandlingPolicy); + loadErrorHandlingPolicy, + sessionKeepaliveMs); } } @@ -232,6 +261,8 @@ public class DefaultDrmSessionManager implements DrmSessionManager { public static final int MODE_RELEASE = 3; /** Number of times to retry for initial provisioning and key request for reporting error. */ public static final int INITIAL_DRM_REQUEST_RETRY_COUNT = 3; + /** Default value for {@link Builder#setSessionKeepaliveMs(long)}. */ + public static final long DEFAULT_SESSION_KEEPALIVE_MS = 5 * 60 * C.MILLIS_PER_SECOND; private static final String TAG = "DefaultDrmSessionMgr"; @@ -244,15 +275,19 @@ public class DefaultDrmSessionManager implements DrmSessionManager { private final boolean playClearSamplesWithoutKeys; private final ProvisioningManagerImpl provisioningManagerImpl; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final ReferenceCountListenerImpl referenceCountListener; + private final long sessionKeepaliveMs; private final List sessions; private final List provisioningSessions; + private final Set keepaliveSessions; private int prepareCallsCount; @Nullable private ExoMediaDrm exoMediaDrm; @Nullable private DefaultDrmSession placeholderDrmSession; @Nullable private DefaultDrmSession noMultiSessionDrmSession; @Nullable private Looper playbackLooper; + private @MonotonicNonNull Handler sessionReleasingHandler; private int mode; @Nullable private byte[] offlineLicenseKeySetId; @@ -336,7 +371,8 @@ public class DefaultDrmSessionManager implements DrmSessionManager { multiSession, /* useDrmSessionsForClearContentTrackTypes= */ new int[0], /* playClearSamplesWithoutKeys= */ false, - new DefaultLoadErrorHandlingPolicy(initialDrmRequestRetryCount)); + new DefaultLoadErrorHandlingPolicy(initialDrmRequestRetryCount), + DEFAULT_SESSION_KEEPALIVE_MS); } private DefaultDrmSessionManager( @@ -347,7 +383,8 @@ public class DefaultDrmSessionManager implements DrmSessionManager { boolean multiSession, int[] useDrmSessionsForClearContentTrackTypes, boolean playClearSamplesWithoutKeys, - LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + long sessionKeepaliveMs) { Assertions.checkNotNull(uuid); Assertions.checkArgument(!C.COMMON_PSSH_UUID.equals(uuid), "Use C.CLEARKEY_UUID instead"); this.uuid = uuid; @@ -359,9 +396,12 @@ public class DefaultDrmSessionManager implements DrmSessionManager { this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; provisioningManagerImpl = new ProvisioningManagerImpl(); + referenceCountListener = new ReferenceCountListenerImpl(); mode = MODE_PLAYBACK; sessions = new ArrayList<>(); provisioningSessions = new ArrayList<>(); + keepaliveSessions = Sets.newIdentityHashSet(); + this.sessionKeepaliveMs = sessionKeepaliveMs; } /** @@ -411,6 +451,13 @@ public class DefaultDrmSessionManager implements DrmSessionManager { @Override public final void release() { if (--prepareCallsCount == 0) { + // Make a local copy, because sessions are removed from this.sessions during release (via + // callback). + List sessions = new ArrayList<>(this.sessions); + for (int i = 0; i < sessions.size(); i++) { + // Release all the keepalive acquisitions. + sessions.get(i).release(/* eventDispatcher= */ null); + } Assertions.checkNotNull(exoMediaDrm).release(); exoMediaDrm = null; } @@ -451,7 +498,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { @Override @Nullable public DrmSession acquirePlaceholderSession(Looper playbackLooper, int trackType) { - assertExpectedPlaybackLooper(playbackLooper); + initPlaybackLooper(playbackLooper); ExoMediaDrm exoMediaDrm = Assertions.checkNotNull(this.exoMediaDrm); boolean avoidPlaceholderDrmSessions = FrameworkMediaCrypto.class.equals(exoMediaDrm.getExoMediaCryptoType()) @@ -465,12 +512,15 @@ public class DefaultDrmSessionManager implements DrmSessionManager { maybeCreateMediaDrmHandler(playbackLooper); if (placeholderDrmSession == null) { DefaultDrmSession placeholderDrmSession = - createNewDefaultSession( - /* schemeDatas= */ Collections.emptyList(), /* isPlaceholderSession= */ true); + createAndAcquireSessionWithRetry( + /* schemeDatas= */ ImmutableList.of(), + /* isPlaceholderSession= */ true, + /* eventDispatcher= */ null); sessions.add(placeholderDrmSession); this.placeholderDrmSession = placeholderDrmSession; + } else { + placeholderDrmSession.acquire(/* eventDispatcher= */ null); } - placeholderDrmSession.acquire(/* eventDispatcher= */ null); return placeholderDrmSession; } @@ -479,7 +529,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { Looper playbackLooper, @Nullable MediaSourceEventDispatcher eventDispatcher, DrmInitData drmInitData) { - assertExpectedPlaybackLooper(playbackLooper); + initPlaybackLooper(playbackLooper); maybeCreateMediaDrmHandler(playbackLooper); @Nullable List schemeDatas = null; @@ -513,13 +563,17 @@ public class DefaultDrmSessionManager implements DrmSessionManager { if (session == null) { // Create a new session. - session = createNewDefaultSession(schemeDatas, /* isPlaceholderSession= */ false); + session = + createAndAcquireSessionWithRetry( + schemeDatas, /* isPlaceholderSession= */ false, eventDispatcher); if (!multiSession) { noMultiSessionDrmSession = session; } sessions.add(session); + } else { + session.acquire(eventDispatcher); } - session.acquire(eventDispatcher); + return session; } @@ -533,9 +587,13 @@ public class DefaultDrmSessionManager implements DrmSessionManager { // Internal methods. - private void assertExpectedPlaybackLooper(Looper playbackLooper) { - Assertions.checkState(this.playbackLooper == null || this.playbackLooper == playbackLooper); - this.playbackLooper = playbackLooper; + private void initPlaybackLooper(Looper playbackLooper) { + if (this.playbackLooper == null) { + this.playbackLooper = playbackLooper; + this.sessionReleasingHandler = new Handler(playbackLooper); + } else { + Assertions.checkState(this.playbackLooper == playbackLooper); + } } private void maybeCreateMediaDrmHandler(Looper playbackLooper) { @@ -544,41 +602,77 @@ public class DefaultDrmSessionManager implements DrmSessionManager { } } - private DefaultDrmSession createNewDefaultSession( - @Nullable List schemeDatas, boolean isPlaceholderSession) { + private DefaultDrmSession createAndAcquireSessionWithRetry( + @Nullable List schemeDatas, + boolean isPlaceholderSession, + @Nullable MediaSourceEventDispatcher eventDispatcher) { + DefaultDrmSession session = + createAndAcquireSession(schemeDatas, isPlaceholderSession, eventDispatcher); + if (session.getState() == DrmSession.STATE_ERROR + && (Util.SDK_INT < 19 + || Assertions.checkNotNull(session.getError()).getCause() + instanceof ResourceBusyException)) { + // We're short on DRM session resources, so eagerly release all our keepalive sessions. + // ResourceBusyException is only available at API 19, so on earlier versions we always + // eagerly release regardless of the underlying error. + if (!keepaliveSessions.isEmpty()) { + // Make a local copy, because sessions are removed from this.timingOutSessions during + // release (via callback). + ImmutableList timingOutSessions = + ImmutableList.copyOf(this.keepaliveSessions); + for (DrmSession timingOutSession : timingOutSessions) { + timingOutSession.release(/* eventDispatcher= */ null); + } + // Undo the acquisitions from createAndAcquireSession(). + session.release(eventDispatcher); + if (sessionKeepaliveMs != C.TIME_UNSET) { + session.release(/* eventDispatcher= */ null); + } + session = createAndAcquireSession(schemeDatas, isPlaceholderSession, eventDispatcher); + } + } + return session; + } + + /** + * Creates a new {@link DefaultDrmSession} and acquires it on behalf of the caller (passing in + * {@code eventDispatcher}). + * + *

If {@link #sessionKeepaliveMs} != {@link C#TIME_UNSET} then acquires it again to allow the + * manager to keep it alive (passing in {@code eventDispatcher=null}. + */ + private DefaultDrmSession createAndAcquireSession( + @Nullable List schemeDatas, + boolean isPlaceholderSession, + @Nullable MediaSourceEventDispatcher eventDispatcher) { Assertions.checkNotNull(exoMediaDrm); // Placeholder sessions should always play clear samples without keys. boolean playClearSamplesWithoutKeys = this.playClearSamplesWithoutKeys | isPlaceholderSession; - return new DefaultDrmSession( - uuid, - exoMediaDrm, - /* provisioningManager= */ provisioningManagerImpl, - /* releaseCallback= */ this::onSessionReleased, - schemeDatas, - mode, - playClearSamplesWithoutKeys, - isPlaceholderSession, - offlineLicenseKeySetId, - keyRequestParameters, - callback, - Assertions.checkNotNull(playbackLooper), - loadErrorHandlingPolicy); - } - - private void onSessionReleased(DefaultDrmSession drmSession) { - sessions.remove(drmSession); - if (placeholderDrmSession == drmSession) { - placeholderDrmSession = null; + DefaultDrmSession session = + new DefaultDrmSession( + uuid, + exoMediaDrm, + /* provisioningManager= */ provisioningManagerImpl, + referenceCountListener, + schemeDatas, + mode, + playClearSamplesWithoutKeys, + isPlaceholderSession, + offlineLicenseKeySetId, + keyRequestParameters, + callback, + Assertions.checkNotNull(playbackLooper), + loadErrorHandlingPolicy); + // Acquire the session once on behalf of the caller to DrmSessionManager - this is the + // reference 'assigned' to the caller which they're responsible for releasing. Do this first, + // to ensure that eventDispatcher receives all events related to the initial + // acquisition/opening. + session.acquire(eventDispatcher); + if (sessionKeepaliveMs != C.TIME_UNSET) { + // Acquire the session once more so the Manager can keep it alive. + session.acquire(/* eventDispatcher= */ null); } - if (noMultiSessionDrmSession == drmSession) { - noMultiSessionDrmSession = null; - } - if (provisioningSessions.size() > 1 && provisioningSessions.get(0) == drmSession) { - // Other sessions were waiting for the released session to complete a provision operation. - // We need to have one of those sessions perform the provision operation instead. - provisioningSessions.get(1).provision(); - } - provisioningSessions.remove(drmSession); + return session; } /** @@ -661,6 +755,52 @@ public class DefaultDrmSessionManager implements DrmSessionManager { } } + private class ReferenceCountListenerImpl implements DefaultDrmSession.ReferenceCountListener { + + @Override + public void onReferenceCountIncremented(DefaultDrmSession session, int newReferenceCount) { + if (sessionKeepaliveMs != C.TIME_UNSET) { + // The session has been acquired elsewhere so we want to cancel our timeout. + keepaliveSessions.remove(session); + Assertions.checkNotNull(sessionReleasingHandler).removeCallbacksAndMessages(session); + } + } + + @Override + public void onReferenceCountDecremented(DefaultDrmSession session, int newReferenceCount) { + if (newReferenceCount == 1 && sessionKeepaliveMs != C.TIME_UNSET) { + // Only the internal keep-alive reference remains, so we can start the timeout. + keepaliveSessions.add(session); + Assertions.checkNotNull(sessionReleasingHandler) + .postAtTime( + () -> { + session.release(/* eventDispatcher= */ null); + }, + session, + /* uptimeMillis= */ SystemClock.uptimeMillis() + sessionKeepaliveMs); + } else if (newReferenceCount == 0) { + // This session is fully released. + sessions.remove(session); + if (placeholderDrmSession == session) { + placeholderDrmSession = null; + } + if (noMultiSessionDrmSession == session) { + noMultiSessionDrmSession = null; + } + if (provisioningSessions.size() > 1 && provisioningSessions.get(0) == session) { + // Other sessions were waiting for the released session to complete a provision operation. + // We need to have one of those sessions perform the provision operation instead. + provisioningSessions.get(1).provision(); + } + provisioningSessions.remove(session); + if (sessionKeepaliveMs != C.TIME_UNSET) { + Assertions.checkNotNull(sessionReleasingHandler).removeCallbacksAndMessages(session); + keepaliveSessions.remove(session); + } + } + } + } + private class MediaDrmEventListener implements OnEventListener { @Override diff --git a/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java index 73f68d1202..3c203f1457 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java @@ -16,9 +16,11 @@ package com.google.android.exoplayer2.drm; import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.SECONDS; import android.os.Looper; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.testutil.FakeExoMediaDrm; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.Assertions; @@ -47,7 +49,7 @@ public class DefaultDrmSessionManagerTest { private static final DrmInitData DRM_INIT_DATA = new DrmInitData(DRM_SCHEME_DATAS); @Test(timeout = 10_000) - public void acquireSessionTriggersKeyLoadAndSessionIsOpened() throws Exception { + public void acquireSession_triggersKeyLoadAndSessionIsOpened() throws Exception { FakeExoMediaDrm.LicenseServer licenseServer = FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); @@ -68,6 +70,151 @@ public class DefaultDrmSessionManagerTest { .containsExactly(FakeExoMediaDrm.KEY_STATUS_KEY, FakeExoMediaDrm.KEY_STATUS_AVAILABLE); } + @Test(timeout = 10_000) + public void keepaliveEnabled_sessionsKeptForRequestedTime() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setSessionKeepaliveMs(10_000) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession drmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + DRM_INIT_DATA); + waitForOpenedWithKeys(drmSession); + + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + drmSession.release(/* eventDispatcher= */ null); + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + ShadowLooper.idleMainLooper(10, SECONDS); + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_RELEASED); + } + + @Test(timeout = 10_000) + public void keepaliveDisabled_sessionsReleasedImmediately() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setSessionKeepaliveMs(C.TIME_UNSET) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession drmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + DRM_INIT_DATA); + waitForOpenedWithKeys(drmSession); + drmSession.release(/* eventDispatcher= */ null); + + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_RELEASED); + } + + @Test(timeout = 10_000) + public void managerRelease_allKeepaliveSessionsImmediatelyReleased() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setSessionKeepaliveMs(10_000) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession drmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + DRM_INIT_DATA); + waitForOpenedWithKeys(drmSession); + drmSession.release(/* eventDispatcher= */ null); + + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + drmSessionManager.release(); + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_RELEASED); + } + + @Test(timeout = 10_000) + public void maxConcurrentSessionsExceeded_allKeepAliveSessionsEagerlyReleased() throws Exception { + ImmutableList secondSchemeDatas = + ImmutableList.of(DRM_SCHEME_DATAS.get(0).copyWithData(TestUtil.createByteArray(4, 5, 6))); + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS, secondSchemeDatas); + DrmInitData secondInitData = new DrmInitData(secondSchemeDatas); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider( + DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm(/* maxConcurrentSessions= */ 1)) + .setSessionKeepaliveMs(10_000) + .setMultiSession(true) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession firstDrmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + DRM_INIT_DATA); + waitForOpenedWithKeys(firstDrmSession); + firstDrmSession.release(/* eventDispatcher= */ null); + + // All external references to firstDrmSession have been released, it's being kept alive by + // drmSessionManager's internal reference. + assertThat(firstDrmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + DrmSession secondDrmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + secondInitData); + // The drmSessionManager had to release firstDrmSession in order to acquire secondDrmSession. + assertThat(firstDrmSession.getState()).isEqualTo(DrmSession.STATE_RELEASED); + + waitForOpenedWithKeys(secondDrmSession); + assertThat(secondDrmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + } + + @Test(timeout = 10_000) + public void sessionReacquired_keepaliveTimeOutCancelled() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setSessionKeepaliveMs(10_000) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession firstDrmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + DRM_INIT_DATA); + waitForOpenedWithKeys(firstDrmSession); + firstDrmSession.release(/* eventDispatcher= */ null); + + ShadowLooper.idleMainLooper(5, SECONDS); + + // Acquire a session for the same init data 5s in to the 10s timeout (so expect the same + // instance). + DrmSession secondDrmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + DRM_INIT_DATA); + assertThat(secondDrmSession).isSameInstanceAs(firstDrmSession); + + // Let the timeout definitely expire, and check the session didn't get released. + ShadowLooper.idleMainLooper(10, SECONDS); + assertThat(secondDrmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + } + private static void waitForOpenedWithKeys(DrmSession drmSession) { // Check the error first, so we get a meaningful failure if there's been an error. assertThat(drmSession.getError()).isNull(); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java index cf422ffab3..c698b2e8b3 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExoMediaDrm.java @@ -20,6 +20,7 @@ import android.media.DeniedByServerException; import android.media.MediaCryptoException; import android.media.MediaDrmException; import android.media.NotProvisionedException; +import android.media.ResourceBusyException; import android.os.Parcel; import android.os.Parcelable; import android.os.PersistableBundle; @@ -57,7 +58,7 @@ import java.util.concurrent.atomic.AtomicInteger; // TODO: Consider replacing this with a Robolectric ShadowMediaDrm so we can use a real // FrameworkMediaDrm. @RequiresApi(29) -public class FakeExoMediaDrm implements ExoMediaDrm { +public final class FakeExoMediaDrm implements ExoMediaDrm { public static final ProvisionRequest DUMMY_PROVISION_REQUEST = new ProvisionRequest(TestUtil.createByteArray(7, 8, 9), "bar.test"); @@ -72,6 +73,7 @@ public class FakeExoMediaDrm implements ExoMediaDrm { private static final ImmutableList VALID_KEY_RESPONSE = TestUtil.createByteList(1, 2, 3); private static final ImmutableList KEY_DENIED_RESPONSE = TestUtil.createByteList(9, 8, 7); + private final int maxConcurrentSessions; private final Map byteProperties; private final Map stringProperties; private final Set> openSessionIds; @@ -82,9 +84,20 @@ public class FakeExoMediaDrm implements ExoMediaDrm { /** * Constructs an instance that returns random and unique {@code sessionIds} for subsequent calls - * to {@link #openSession()}. + * to {@link #openSession()} with no limit on the number of concurrent open sessions. */ public FakeExoMediaDrm() { + this(/* maxConcurrentSessions= */ Integer.MAX_VALUE); + } + + /** + * Constructs an instance that returns random and unique {@code sessionIds} for subsequent calls + * to {@link #openSession()} with a limit on the number of concurrent open sessions. + * + * @param maxConcurrentSessions The max number of sessions allowed to be open simultaneously. + */ + public FakeExoMediaDrm(int maxConcurrentSessions) { + this.maxConcurrentSessions = maxConcurrentSessions; byteProperties = new HashMap<>(); stringProperties = new HashMap<>(); openSessionIds = new HashSet<>(); @@ -114,6 +127,9 @@ public class FakeExoMediaDrm implements ExoMediaDrm { @Override public byte[] openSession() throws MediaDrmException { Assertions.checkState(referenceCount > 0); + if (openSessionIds.size() >= maxConcurrentSessions) { + throw new ResourceBusyException("Too many sessions open. max=" + maxConcurrentSessions); + } byte[] sessionId = TestUtil.buildTestData(/* length= */ 10, sessionIdGenerator.incrementAndGet()); if (!openSessionIds.add(toByteList(sessionId))) {