blob: 3af5ad5949522fb777338d3586d448d237f9c6b5 [file] [log] [blame]
// Copyright 2013 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
//
// Modifications Copyright 2017 The Cobalt Authors. All Rights Reserved.
//
// 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 dev.cobalt.media;
import static dev.cobalt.media.Log.TAG;
import android.media.DeniedByServerException;
import android.media.MediaCrypto;
import android.media.MediaCryptoException;
import android.media.MediaDrm;
import android.media.MediaDrm.OnEventListener;
import android.media.MediaDrmException;
import android.media.NotProvisionedException;
import android.media.UnsupportedSchemeException;
import android.os.Build;
import androidx.annotation.RequiresApi;
import dev.cobalt.coat.CobaltHttpHelper;
import dev.cobalt.util.Log;
import dev.cobalt.util.UsedByNative;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
/** A wrapper of the android MediaDrm class. */
@UsedByNative
public class MediaDrmBridge {
// Implementation Notes:
// - A media crypto session (mMediaCryptoSession) is opened after MediaDrm
// is created. This session will NOT be added to mSessionIds and will only
// be used to create the MediaCrypto object.
// - Each createSession() call creates a new session. All created sessions
// are managed in mSessionIds.
// - Whenever NotProvisionedException is thrown, we will clean up the
// current state and start the provisioning process.
// - When provisioning is finished, we will try to resume suspended
// operations:
// a) Create the media crypto session if it's not created.
// b) Finish createSession() if previous createSession() was interrupted
// by a NotProvisionedException.
// - Whenever an unexpected error occurred, we'll call release() to release
// all resources immediately, clear all states and fail all pending
// operations. After that all calls to this object will fail (e.g. return
// null or reject the promise). All public APIs and callbacks should check
// mMediaBridge to make sure release() hasn't been called.
private static final char[] HEX_CHAR_LOOKUP = "0123456789ABCDEF".toCharArray();
private static final long INVALID_NATIVE_MEDIA_DRM_BRIDGE = 0;
// The value of this must stay in sync with kSbDrmTicketInvalid in "starboard/drm.h"
private static final int SB_DRM_TICKET_INVALID = Integer.MIN_VALUE;
// Scheme UUID for Widevine. See http://dashif.org/identifiers/protection/
private static final UUID WIDEVINE_UUID = UUID.fromString("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed");
// Deprecated in API 26, but we still log it on earlier devices.
// We do handle STATUS_EXPIRED in nativeOnKeyStatusChange() for API 23+ devices.
@SuppressWarnings("deprecation")
private static final int MEDIA_DRM_EVENT_KEY_EXPIRED = MediaDrm.EVENT_KEY_EXPIRED;
private MediaDrm mMediaDrm;
private long mNativeMediaDrmBridge;
private UUID mSchemeUUID;
// A session only for the purpose of creating a MediaCrypto object. Created
// after construction, or after the provisioning process is successfully
// completed. No getKeyRequest() should be called on |mMediaCryptoSession|.
private byte[] mMediaCryptoSession;
// The map of all opened sessions (excluding mMediaCryptoSession) to their
// mime types.
private HashMap<ByteBuffer, String> mSessionIds = new HashMap<>();
private MediaCrypto mMediaCrypto;
// Return value type for calls to updateSession(), which contains whether or not the call
// succeeded, and optionally an error message (that is empty on success).
@UsedByNative
private static class UpdateSessionResult {
public enum Status {
SUCCESS,
FAILURE
}
// Whether or not the update session attempt succeeded or failed.
private boolean mIsSuccess;
// Descriptive error message or details, in the scenario where the update session call failed.
private String mErrorMessage;
public UpdateSessionResult(Status status, String errorMessage) {
this.mIsSuccess = status == Status.SUCCESS;
this.mErrorMessage = errorMessage;
}
@UsedByNative
public boolean isSuccess() {
return mIsSuccess;
}
@UsedByNative
public String getErrorMessage() {
return mErrorMessage;
}
}
/**
* Create a new MediaDrmBridge with the Widevine crypto scheme.
*
* @param nativeMediaDrmBridge The native owner of this class.
*/
@UsedByNative
static MediaDrmBridge create(long nativeMediaDrmBridge) {
UUID cryptoScheme = WIDEVINE_UUID;
if (!MediaDrm.isCryptoSchemeSupported(cryptoScheme)) {
return null;
}
MediaDrmBridge mediaDrmBridge = null;
try {
mediaDrmBridge = new MediaDrmBridge(cryptoScheme, nativeMediaDrmBridge);
Log.d(TAG, "MediaDrmBridge successfully created.");
} catch (UnsupportedSchemeException e) {
Log.e(TAG, "Unsupported DRM scheme", e);
return null;
} catch (IllegalArgumentException e) {
Log.e(TAG, "Failed to create MediaDrmBridge", e);
return null;
} catch (IllegalStateException e) {
Log.e(TAG, "Failed to create MediaDrmBridge", e);
return null;
}
if (!mediaDrmBridge.createMediaCrypto()) {
return null;
}
return mediaDrmBridge;
}
/**
* Check whether the Widevine crypto scheme is supported.
*
* @return true if the container and the crypto scheme is supported, or false otherwise.
*/
@UsedByNative
static boolean isWidevineCryptoSchemeSupported() {
return MediaDrm.isCryptoSchemeSupported(WIDEVINE_UUID);
}
/**
* Check whether the Widevine crypto scheme is supported for the given container. If
* |containerMimeType| is an empty string, we just return whether the crypto scheme is supported.
*
* @return true if the container and the crypto scheme is supported, or false otherwise.
*/
@UsedByNative
static boolean isWidevineCryptoSchemeSupported(String containerMimeType) {
if (containerMimeType.isEmpty()) {
return isWidevineCryptoSchemeSupported();
}
return MediaDrm.isCryptoSchemeSupported(WIDEVINE_UUID, containerMimeType);
}
/** Destroy the MediaDrmBridge object. */
@UsedByNative
void destroy() {
mNativeMediaDrmBridge = INVALID_NATIVE_MEDIA_DRM_BRIDGE;
if (mMediaDrm != null) {
release();
}
}
@UsedByNative
void createSession(int ticket, byte[] initData, String mime) {
Log.d(TAG, "createSession()");
if (mMediaDrm == null) {
Log.e(TAG, "createSession() called when MediaDrm is null.");
return;
}
boolean newSessionOpened = false;
byte[] sessionId = null;
try {
sessionId = openSession();
if (sessionId == null) {
Log.e(TAG, "Open session failed.");
return;
}
newSessionOpened = true;
if (sessionExists(sessionId)) {
Log.e(TAG, "Opened session that already exists.");
return;
}
MediaDrm.KeyRequest request = null;
request = getKeyRequest(sessionId, initData, mime);
if (request == null) {
try {
// Some implementations let this method throw exceptions.
mMediaDrm.closeSession(sessionId);
} catch (Exception e) {
Log.e(TAG, "closeSession failed", e);
}
Log.e(TAG, "Generate request failed.");
return;
}
// Success!
Log.d(
TAG,
String.format("createSession(): Session (%s) created.", bytesToHexString(sessionId)));
mSessionIds.put(ByteBuffer.wrap(sessionId), mime);
onSessionMessage(ticket, sessionId, request);
} catch (NotProvisionedException e) {
Log.e(TAG, "Device not provisioned", e);
if (newSessionOpened) {
try {
// Some implementations let this method throw exceptions.
mMediaDrm.closeSession(sessionId);
} catch (Exception ex) {
Log.e(TAG, "closeSession failed", ex);
}
}
attemptProvisioning();
}
}
/**
* Update a session with response.
*
* @param sessionId Reference ID of session to be updated.
* @param response Response data from the server.
*/
@UsedByNative
UpdateSessionResult updateSession(int ticket, byte[] sessionId, byte[] response) {
Log.d(TAG, "updateSession()");
if (mMediaDrm == null) {
Log.e(TAG, "updateSession() called when MediaDrm is null.");
return new UpdateSessionResult(
UpdateSessionResult.Status.FAILURE,
"Null MediaDrm object when calling updateSession(). StackTrace: "
+ android.util.Log.getStackTraceString(new Throwable()));
}
if (!sessionExists(sessionId)) {
Log.e(TAG, "updateSession tried to update a session that does not exist.");
return new UpdateSessionResult(
UpdateSessionResult.Status.FAILURE,
"Failed to update session because it does not exist. StackTrace: "
+ android.util.Log.getStackTraceString(new Throwable()));
}
try {
try {
mMediaDrm.provideKeyResponse(sessionId, response);
} catch (IllegalStateException e) {
// This is not really an exception. Some error codes are incorrectly
// reported as an exception.
Log.e(TAG, "Exception intentionally caught when calling provideKeyResponse()", e);
}
Log.d(
TAG, String.format("Key successfully added for session %s", bytesToHexString(sessionId)));
if (Build.VERSION.SDK_INT < 23) {
// Pass null to indicate that KeyStatus isn't supported.
nativeOnKeyStatusChange(mNativeMediaDrmBridge, sessionId, null);
}
return new UpdateSessionResult(UpdateSessionResult.Status.SUCCESS, "");
} catch (NotProvisionedException e) {
// TODO: Should we handle this?
Log.e(TAG, "Failed to provide key response", e);
release();
return new UpdateSessionResult(
UpdateSessionResult.Status.FAILURE,
"Update session failed due to lack of provisioning. StackTrace: "
+ android.util.Log.getStackTraceString(e));
} catch (DeniedByServerException e) {
Log.e(TAG, "Failed to provide key response.", e);
release();
return new UpdateSessionResult(
UpdateSessionResult.Status.FAILURE,
"Update session failed because we were denied by server. StackTrace: "
+ android.util.Log.getStackTraceString(e));
} catch (Exception e) {
Log.e(TAG, "", e);
release();
return new UpdateSessionResult(
UpdateSessionResult.Status.FAILURE,
"Update session failed. Caught exception: "
+ e.getMessage()
+ " StackTrace: "
+ android.util.Log.getStackTraceString(e));
}
}
/**
* Close a session that was previously created by createSession().
*
* @param sessionId ID of session to be closed.
*/
@UsedByNative
void closeSession(byte[] sessionId) {
Log.d(TAG, "closeSession()");
if (mMediaDrm == null) {
Log.e(TAG, "closeSession() called when MediaDrm is null.");
return;
}
if (!sessionExists(sessionId)) {
Log.e(TAG, "Invalid sessionId in closeSession(): " + bytesToHexString(sessionId));
return;
}
try {
// Some implementations don't have removeKeys.
// https://bugs.chromium.org/p/chromium/issues/detail?id=475632
mMediaDrm.removeKeys(sessionId);
} catch (Exception e) {
Log.e(TAG, "removeKeys failed: ", e);
}
try {
// Some implementations let this method throw exceptions.
mMediaDrm.closeSession(sessionId);
} catch (Exception e) {
Log.e(TAG, "closeSession failed: ", e);
}
mSessionIds.remove(ByteBuffer.wrap(sessionId));
Log.d(TAG, String.format("Session %s closed", bytesToHexString(sessionId)));
}
@UsedByNative
MediaCrypto getMediaCrypto() {
return mMediaCrypto;
}
private MediaDrmBridge(UUID schemeUUID, long nativeMediaDrmBridge)
throws android.media.UnsupportedSchemeException {
mSchemeUUID = schemeUUID;
mMediaDrm = new MediaDrm(schemeUUID);
mNativeMediaDrmBridge = nativeMediaDrmBridge;
if (!isNativeMediaDrmBridgeValid()) {
throw new IllegalArgumentException(
String.format("Invalid nativeMediaDrmBridge value: |%d|.", nativeMediaDrmBridge));
}
mMediaDrm.setOnEventListener(
new OnEventListener() {
@Override
public void onEvent(MediaDrm md, byte[] sessionId, int event, int extra, byte[] data) {
if (sessionId == null) {
Log.e(TAG, "EventListener: Null session.");
return;
}
if (!sessionExists(sessionId)) {
Log.e(
TAG,
String.format("EventListener: Invalid session %s", bytesToHexString(sessionId)));
return;
}
switch (event) {
case MediaDrm.EVENT_KEY_REQUIRED:
Log.d(TAG, "MediaDrm.EVENT_KEY_REQUIRED");
String mime = mSessionIds.get(ByteBuffer.wrap(sessionId));
MediaDrm.KeyRequest request = null;
try {
request = getKeyRequest(sessionId, data, mime);
} catch (NotProvisionedException e) {
Log.e(TAG, "Device not provisioned", e);
if (!attemptProvisioning()) {
Log.e(TAG, "Failed to provision device when responding to EVENT_KEY_REQUIRED");
return;
}
// If we supposedly successfully provisioned ourselves, then try to create a
// request again.
try {
request = getKeyRequest(sessionId, data, mime);
} catch (NotProvisionedException e2) {
Log.e(
TAG,
"Device still not provisioned after supposedly successful provisioning",
e2);
return;
}
}
if (request != null) {
onSessionMessage(SB_DRM_TICKET_INVALID, sessionId, request);
} else {
Log.e(TAG, "EventListener: getKeyRequest failed.");
return;
}
break;
case MEDIA_DRM_EVENT_KEY_EXPIRED:
Log.d(TAG, "MediaDrm.EVENT_KEY_EXPIRED");
break;
case MediaDrm.EVENT_VENDOR_DEFINED:
Log.d(TAG, "MediaDrm.EVENT_VENDOR_DEFINED");
break;
default:
Log.e(TAG, "Invalid DRM event " + event);
return;
}
}
});
if (Build.VERSION.SDK_INT >= 23) {
setOnKeyStatusChangeListenerV23();
}
mMediaDrm.setPropertyString("privacyMode", "enable");
mMediaDrm.setPropertyString("sessionSharing", "enable");
}
@RequiresApi(23)
private void setOnKeyStatusChangeListenerV23() {
mMediaDrm.setOnKeyStatusChangeListener(
new MediaDrm.OnKeyStatusChangeListener() {
@Override
public void onKeyStatusChange(
MediaDrm md,
byte[] sessionId,
List<MediaDrm.KeyStatus> keyInformation,
boolean hasNewUsableKey) {
nativeOnKeyStatusChange(
mNativeMediaDrmBridge,
sessionId,
keyInformation.toArray(new MediaDrm.KeyStatus[keyInformation.size()]));
}
},
null);
}
/** Convert byte array to hex string for logging. */
private static String bytesToHexString(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (int i = 0; i < bytes.length; ++i) {
hexString.append(HEX_CHAR_LOOKUP[bytes[i] >>> 4]);
hexString.append(HEX_CHAR_LOOKUP[bytes[i] & 0xf]);
}
return hexString.toString();
}
private void onSessionMessage(
int ticket, final byte[] sessionId, final MediaDrm.KeyRequest request) {
if (!isNativeMediaDrmBridgeValid()) {
return;
}
int requestType = MediaDrm.KeyRequest.REQUEST_TYPE_INITIAL;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
requestType = request.getRequestType();
} else {
// Prior to M, getRequestType() is not supported. Do our best guess here: Assume
// requests with a URL are renewals and all others are initial requests.
requestType =
request.getDefaultUrl().isEmpty()
? MediaDrm.KeyRequest.REQUEST_TYPE_INITIAL
: MediaDrm.KeyRequest.REQUEST_TYPE_RENEWAL;
}
nativeOnSessionMessage(
mNativeMediaDrmBridge, ticket, sessionId, requestType, request.getData());
}
/**
* Get a key request.
*
* @param sessionId ID of session on which we need to get the key request.
* @param data Data needed to get the key request.
* @param mime Mime type to get the key request.
* @return the key request.
*/
private MediaDrm.KeyRequest getKeyRequest(byte[] sessionId, byte[] data, String mime)
throws android.media.NotProvisionedException {
if (mMediaDrm == null) {
throw new IllegalStateException("mMediaDrm cannot be null in getKeyRequest");
}
if (mMediaCryptoSession == null) {
throw new IllegalStateException("mMediaCryptoSession cannot be null in getKeyRequest.");
}
// TODO: Cannot do this during provisioning pending.
HashMap<String, String> optionalParameters = new HashMap<>();
MediaDrm.KeyRequest request = null;
try {
request =
mMediaDrm.getKeyRequest(
sessionId, data, mime, MediaDrm.KEY_TYPE_STREAMING, optionalParameters);
} catch (IllegalStateException e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
&& e instanceof android.media.MediaDrm.MediaDrmStateException) {
Log.e(TAG, "MediaDrmStateException fired during getKeyRequest().", e);
}
}
String result = (request != null) ? "succeeded" : "failed";
Log.d(TAG, String.format("getKeyRequest %s!", result));
return request;
}
/**
* Create a MediaCrypto object.
*
* @return false upon fatal error in creating MediaCrypto. Returns true otherwise, including the
* following two cases: 1. MediaCrypto is successfully created and notified. 2. Device is not
* provisioned and MediaCrypto creation will be tried again after the provisioning process is
* completed.
* <p>When false is returned, the caller should call release(), which will notify the native
* code with a null MediaCrypto, if needed.
*/
private boolean createMediaCrypto() {
if (mMediaDrm == null) {
throw new IllegalStateException("Cannot create media crypto with null mMediaDrm.");
}
if (mMediaCryptoSession != null) {
throw new IllegalStateException(
"Cannot create media crypto with non-null mMediaCryptoSession.");
}
// TODO: Cannot do this during provisioning pending.
// Open media crypto session.
try {
mMediaCryptoSession = openSession();
} catch (NotProvisionedException e) {
Log.d(TAG, "Device not provisioned", e);
if (!attemptProvisioning()) {
Log.e(TAG, "Failed to provision device during MediaCrypto creation.");
return false;
}
try {
mMediaCryptoSession = openSession();
} catch (NotProvisionedException e2) {
Log.e(TAG, "Device still not provisioned after supposedly successful provisioning", e2);
return false;
}
}
if (mMediaCryptoSession == null) {
Log.e(TAG, "Cannot create MediaCrypto Session.");
return false;
}
Log.d(
TAG,
String.format("MediaCrypto Session created: %s", bytesToHexString(mMediaCryptoSession)));
// Create MediaCrypto object.
try {
if (MediaCrypto.isCryptoSchemeSupported(mSchemeUUID)) {
MediaCrypto mediaCrypto = new MediaCrypto(mSchemeUUID, mMediaCryptoSession);
Log.d(TAG, "MediaCrypto successfully created!");
mMediaCrypto = mediaCrypto;
return true;
} else {
Log.e(TAG, "Cannot create MediaCrypto for unsupported scheme.");
}
} catch (MediaCryptoException e) {
Log.e(TAG, "Cannot create MediaCrypto", e);
}
try {
// Some implementations let this method throw exceptions.
mMediaDrm.closeSession(mMediaCryptoSession);
} catch (Exception e) {
Log.e(TAG, "closeSession failed: ", e);
}
mMediaCryptoSession = null;
return false;
}
/**
* Open a new session.
*
* @return ID of the session opened. Returns null if unexpected error happened.
*/
private byte[] openSession() throws android.media.NotProvisionedException {
Log.d(TAG, "openSession()");
if (mMediaDrm == null) {
throw new IllegalStateException("mMediaDrm cannot be null in openSession");
}
try {
byte[] sessionId = mMediaDrm.openSession();
// Make a clone here in case the underlying byte[] is modified.
return sessionId.clone();
} catch (RuntimeException e) { // TODO: Drop this?
Log.e(TAG, "Cannot open a new session", e);
release();
return null;
} catch (NotProvisionedException e) {
// Throw NotProvisionedException so that we can attemptProvisioning().
throw e;
} catch (MediaDrmException e) {
// Other MediaDrmExceptions (e.g. ResourceBusyException) are not
// recoverable.
Log.e(TAG, "Cannot open a new session", e);
release();
return null;
}
}
/**
* Attempt to get the device that we are currently running on provisioned.
*
* @return whether provisioning was successful or not.
*/
private boolean attemptProvisioning() {
Log.d(TAG, "attemptProvisioning()");
MediaDrm.ProvisionRequest request = mMediaDrm.getProvisionRequest();
String url = request.getDefaultUrl() + "&signedRequest=" + new String(request.getData());
byte[] response = new CobaltHttpHelper().performDrmHttpPost(url);
if (response == null) {
return false;
}
try {
mMediaDrm.provideProvisionResponse(response);
return true;
} catch (android.media.DeniedByServerException e) {
Log.e(TAG, "failed to provide provision response", e);
} catch (java.lang.IllegalStateException e) {
Log.e(TAG, "failed to provide provision response", e);
}
return false;
}
/**
* Check whether |sessionId| is an existing session ID, excluding the media crypto session.
*
* @param sessionId Crypto session Id.
* @return true if |sessionId| exists, false otherwise.
*/
private boolean sessionExists(byte[] sessionId) {
if (mMediaCryptoSession == null) {
if (!mSessionIds.isEmpty()) {
throw new IllegalStateException(
"mSessionIds must be empty if crypto session does not exist.");
}
Log.e(TAG, "Session doesn't exist because media crypto session is not created.");
return false;
}
return !Arrays.equals(sessionId, mMediaCryptoSession)
&& mSessionIds.containsKey(ByteBuffer.wrap(sessionId));
}
/** Release all allocated resources and finish all pending operations. */
private void release() {
// Note that mNativeMediaDrmBridge may have already been reset (see destroy()).
if (mMediaDrm == null) {
throw new IllegalStateException("Called release with null mMediaDrm.");
}
// Close all open sessions.
for (ByteBuffer sessionId : mSessionIds.keySet()) {
try {
// Some implementations don't have removeKeys.
// https://bugs.chromium.org/p/chromium/issues/detail?id=475632
mMediaDrm.removeKeys(sessionId.array());
} catch (Exception e) {
Log.e(TAG, "removeKeys failed: ", e);
}
try {
// Some implementations let this method throw exceptions.
mMediaDrm.closeSession(sessionId.array());
} catch (Exception e) {
Log.e(TAG, "closeSession failed: ", e);
}
Log.d(
TAG,
String.format("Successfully closed session (%s)", bytesToHexString(sessionId.array())));
}
mSessionIds.clear();
mSessionIds = null;
// Close mMediaCryptoSession if it's open.
if (mMediaCryptoSession != null) {
try {
// Some implementations let this method throw exceptions.
mMediaDrm.closeSession(mMediaCryptoSession);
} catch (Exception e) {
Log.e(TAG, "closeSession failed: ", e);
}
mMediaCryptoSession = null;
}
if (mMediaDrm != null) {
mMediaDrm.release();
mMediaDrm = null;
}
}
private boolean isNativeMediaDrmBridgeValid() {
return mNativeMediaDrmBridge != INVALID_NATIVE_MEDIA_DRM_BRIDGE;
}
private native void nativeOnSessionMessage(
long nativeMediaDrmBridge, int ticket, byte[] sessionId, int requestType, byte[] message);
private native void nativeOnKeyStatusChange(
long nativeMediaDrmBridge, byte[] sessionId, MediaDrm.KeyStatus[] keyInformation);
}