From 681a05d004cee9b66b58d0575f8a3d0e241995ba Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 8 Nov 2017 07:56:16 -0800 Subject: [PATCH] Work around incorrect ClearKey encoding prior to O-MR1 Issue: #3138 ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=175006223 --- .../android/exoplayer2/drm/ClearKeyUtil.java | 109 ++++++++++++++++++ .../exoplayer2/drm/DefaultDrmSession.java | 22 +++- .../exoplayer2/drm/FrameworkMediaDrm.java | 3 +- .../google/android/exoplayer2/util/Util.java | 10 ++ .../exoplayer2/drm/ClearKeyUtilTest.java | 64 ++++++++++ 5 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/drm/ClearKeyUtil.java create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/drm/ClearKeyUtilTest.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/ClearKeyUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/ClearKeyUtil.java new file mode 100644 index 00000000000..ee337dcc512 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/ClearKeyUtil.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2017 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 com.google.android.exoplayer2.drm; + +import android.util.Log; +import com.google.android.exoplayer2.util.Util; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Utility methods for ClearKey. + */ +/* package */ final class ClearKeyUtil { + + private static final String TAG = "ClearKeyUtil"; + private static final Pattern REQUEST_KIDS_PATTERN = Pattern.compile("\"kids\":\\[\"(.*?)\"]"); + + private ClearKeyUtil() {} + + /** + * Adjusts ClearKey request data obtained from the Android ClearKey CDM to be spec compliant. + * + * @param request The request data. + * @return The adjusted request data. + */ + public static byte[] adjustRequestData(byte[] request) { + if (Util.SDK_INT >= 27) { + return request; + } + // Prior to O-MR1 the ClearKey CDM encoded the values in the "kids" array using Base64 rather + // than Base64Url. See [Internal: b/64388098]. Any "/" characters that ended up in the request + // as a result were not escaped as "\/". We know the exact request format from the platform's + // InitDataParser.cpp, so we can use a regexp rather than parsing the JSON. + String requestString = Util.fromUtf8Bytes(request); + Matcher requestKidsMatcher = REQUEST_KIDS_PATTERN.matcher(requestString); + if (!requestKidsMatcher.find()) { + Log.e(TAG, "Failed to adjust request data: " + requestString); + return request; + } + int kidsStartIndex = requestKidsMatcher.start(1); + int kidsEndIndex = requestKidsMatcher.end(1); + StringBuilder adjustedRequestBuilder = new StringBuilder(requestString); + base64ToBase64Url(adjustedRequestBuilder, kidsStartIndex, kidsEndIndex); + return Util.getUtf8Bytes(adjustedRequestBuilder.toString()); + } + + /** + * Adjusts ClearKey response data to be suitable for providing to the Android ClearKey CDM. + * + * @param response The response data. + * @return The adjusted response data. + */ + public static byte[] adjustResponseData(byte[] response) { + if (Util.SDK_INT >= 27) { + return response; + } + // Prior to O-MR1 the ClearKey CDM expected Base64 encoding rather than Base64Url encoding for + // the "k" and "kid" strings. See [Internal: b/64388098]. + try { + JSONObject responseJson = new JSONObject(Util.fromUtf8Bytes(response)); + JSONArray keysArray = responseJson.getJSONArray("keys"); + for (int i = 0; i < keysArray.length(); i++) { + JSONObject key = keysArray.getJSONObject(i); + key.put("k", base64UrlToBase64(key.getString("k"))); + key.put("kid", base64UrlToBase64(key.getString("kid"))); + } + return Util.getUtf8Bytes(responseJson.toString()); + } catch (JSONException e) { + Log.e(TAG, "Failed to adjust response data: " + Util.fromUtf8Bytes(response), e); + return response; + } + } + + private static void base64ToBase64Url(StringBuilder base64, int startIndex, int endIndex) { + for (int i = startIndex; i < endIndex; i++) { + switch (base64.charAt(i)) { + case '+': + base64.setCharAt(i, '-'); + break; + case '/': + base64.setCharAt(i, '_'); + break; + default: + break; + } + } + } + + private static String base64UrlToBase64(String base64) { + return base64.replace('-', '+').replace('_', '/'); + } + +} 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 688fff48fb1..c391b7035d5 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 @@ -362,6 +362,20 @@ private void postKeyRequest(int type, boolean allowRetry) { try { KeyRequest request = mediaDrm.getKeyRequest(scope, initData, mimeType, type, optionalKeyRequestParameters); + if (C.CLEARKEY_UUID.equals(uuid)) { + final byte[] data = ClearKeyUtil.adjustRequestData(request.getData()); + final String defaultUrl = request.getDefaultUrl(); + request = new KeyRequest() { + @Override + public byte[] getData() { + return data; + } + @Override + public String getDefaultUrl() { + return defaultUrl; + } + }; + } postRequestHandler.obtainMessage(MSG_KEYS, request, allowRetry).sendToTarget(); } catch (Exception e) { onKeysError(e); @@ -380,8 +394,12 @@ private void onKeyResponse(Object response) { } try { + byte[] responseData = (byte[]) response; + if (C.CLEARKEY_UUID.equals(uuid)) { + responseData = ClearKeyUtil.adjustResponseData(responseData); + } if (mode == DefaultDrmSessionManager.MODE_RELEASE) { - mediaDrm.provideKeyResponse(offlineLicenseKeySetId, (byte[]) response); + mediaDrm.provideKeyResponse(offlineLicenseKeySetId, responseData); if (eventHandler != null && eventListener != null) { eventHandler.post(new Runnable() { @Override @@ -391,7 +409,7 @@ public void run() { }); } } else { - byte[] keySetId = mediaDrm.provideKeyResponse(sessionId, (byte[]) response); + byte[] keySetId = mediaDrm.provideKeyResponse(sessionId, responseData); if ((mode == DefaultDrmSessionManager.MODE_DOWNLOAD || (mode == DefaultDrmSessionManager.MODE_PLAYBACK && offlineLicenseKeySetId != null)) && keySetId != null && keySetId.length != 0) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java index c3ab3462d9b..517ca9247c2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java @@ -25,6 +25,7 @@ import android.media.NotProvisionedException; import android.media.UnsupportedSchemeException; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; @@ -74,7 +75,7 @@ public void setOnEventListener( final ExoMediaDrm.OnEventListener listener) { mediaDrm.setOnEventListener(listener == null ? null : new MediaDrm.OnEventListener() { @Override - public void onEvent(@NonNull MediaDrm md, @NonNull byte[] sessionId, int event, int extra, + public void onEvent(@NonNull MediaDrm md, @Nullable byte[] sessionId, int event, int extra, byte[] data) { listener.onEvent(FrameworkMediaDrm.this, sessionId, event, extra, data); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java index 24132e400c0..f47caa046a9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -246,6 +246,16 @@ public static String normalizeLanguageCode(String language) { return language == null ? null : new Locale(language).getLanguage(); } + /** + * Returns a new {@link String} constructed by decoding UTF-8 encoded bytes. + * + * @param bytes The UTF-8 encoded bytes to decode. + * @return The string. + */ + public static String fromUtf8Bytes(byte[] bytes) { + return new String(bytes, Charset.forName(C.UTF8_NAME)); + } + /** * Returns a new byte array containing the code points of a {@link String} encoded using UTF-8. * diff --git a/library/core/src/test/java/com/google/android/exoplayer2/drm/ClearKeyUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/drm/ClearKeyUtilTest.java new file mode 100644 index 00000000000..01ab9ea9aa4 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/drm/ClearKeyUtilTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2017 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 com.google.android.exoplayer2.drm; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.android.exoplayer2.C; +import java.nio.charset.Charset; +import java.util.Arrays; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Unit test for {@link ClearKeyUtil}. + */ +// TODO: When API level 27 is supported, add tests that check the adjust methods are no-ops. +@RunWith(RobolectricTestRunner.class) +public final class ClearKeyUtilTest { + + @Config(sdk = 26, manifest = Config.NONE) + @Test + public void testAdjustResponseDataV26() { + byte[] data = ("{\"keys\":[{" + + "\"k\":\"abc_def-\"," + + "\"kid\":\"ab_cde-f\"}]," + + "\"type\":\"abc_def-" + + "\"}").getBytes(Charset.forName(C.UTF8_NAME)); + // We expect "-" and "_" to be replaced with "+" and "\/" (forward slashes need to be escaped in + // JSON respectively, for "k" and "kid" only. + byte[] expected = ("{\"keys\":[{" + + "\"k\":\"abc\\/def+\"," + + "\"kid\":\"ab\\/cde+f\"}]," + + "\"type\":\"abc_def-" + + "\"}").getBytes(Charset.forName(C.UTF8_NAME)); + assertThat(Arrays.equals(expected, ClearKeyUtil.adjustResponseData(data))).isTrue(); + } + + @Config(sdk = 26, manifest = Config.NONE) + @Test + public void testAdjustRequestDataV26() { + byte[] data = "{\"kids\":[\"abc+def/\",\"ab+cde/f\"],\"type\":\"abc+def/\"}" + .getBytes(Charset.forName(C.UTF8_NAME)); + // We expect "+" and "/" to be replaced with "-" and "_" respectively, for "kids". + byte[] expected = "{\"kids\":[\"abc-def_\",\"ab-cde_f\"],\"type\":\"abc+def/\"}" + .getBytes(Charset.forName(C.UTF8_NAME)); + assertThat(Arrays.equals(expected, ClearKeyUtil.adjustRequestData(data))).isTrue(); + } + +}