diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/.gitignore b/libwvdrmengine/test/java/FullDecryptPathTesting/.gitignore new file mode 100644 index 00000000..db32c745 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/.gitignore @@ -0,0 +1,95 @@ +# Android studio files: +local.properties +.gradle/ +build/ +captures/ +gradle-app.setting +.externalNativeBuild +*.iml +vcs.xml + +# Created by https://www.gitignore.io/api/intellij +# Edit at https://www.gitignore.io/?templates=intellij + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +.idea/sonarlint + +# End of https://www.gitignore.io/api/intellij diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/.idea/modules.xml b/libwvdrmengine/test/java/FullDecryptPathTesting/.idea/modules.xml new file mode 100644 index 00000000..b6f7f652 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/.idea/modules.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/.idea/runConfigurations.xml b/libwvdrmengine/test/java/FullDecryptPathTesting/.idea/runConfigurations.xml new file mode 100644 index 00000000..7f68460d --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/.idea/runConfigurations.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/FullDecryptPathTesting.apk b/libwvdrmengine/test/java/FullDecryptPathTesting/FullDecryptPathTesting.apk new file mode 100644 index 00000000..c429c100 Binary files /dev/null and b/libwvdrmengine/test/java/FullDecryptPathTesting/FullDecryptPathTesting.apk differ diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/README.md b/libwvdrmengine/test/java/FullDecryptPathTesting/README.md new file mode 100644 index 00000000..659259dc --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/README.md @@ -0,0 +1,90 @@ +# Full Decrypt Path Testing Application + +Released August, 2019. + +This is the Full Decrypt Path Testing application. The application is used +to test the full decrypt path of OEMCrypto. + +The app computes a hash of a clear frame, encrypts the frame, and sends both to +OEMCrypto. OEMCrypto should decrypt, compute a hash, and then verify the +hash. The app will display any errors in the hash. + +## Getting Started + +If OEMCrypto on the device supports CRC32 hash, then the application should work +"out of the box". Install it using + adb install FullDecryptPathTesting.apk + +To start a batch of tests, click on the "Start" button. The device needs to be +connected to WiFi to fetch a license. Once the license is installed, it should +cycle through a set of predefined tests and a bunch of random tests. The video +surface should display a single frame, which is the FDPT logo. + +Press the "Clear" button to clear the logs if you want to run the tests again. + +## Settings + +To change which test to run, click on the "Setup" button. The default settings +run two of the four standard modes, "cenc" and "cbcs" with 5000 randomly +generated tests for each mode. + +## Running on a production device + +This application is intended to test decryption on a device running the full +Android stack. However, most Level 1 OEMCrypto implementations will not support +the decrypt hash feature on a production device. The feature will be optimized +out on production devices. For this reason, it should not be suprising if no +tests run when the settings for "Use Level 1" and "Use Secure Buffer" are set to +true. + +## Running the application from adb + +The settings filed are stored on the device in the shared preferences +directory. You can pull the XML file from the device, edit it, and push it back +to the device: +```shell +DIR=/data/data/com.google.widevine.fulldecryptpathtesting/shared_prefs +FILE=com.google.widevine.fulldecryptpathtesting_preferences.xml +adb pull $DIR/$FILE +``` +Now edit the xml file on your host computer. You can then push the file back +to the directory. You might have to force-stop the application first, to force +the preferences to be read again. +```shell +adb shell am force-stop com.google.widevine.fulldecryptpathtesting +adb push $FILE $DIR/$FILE +``` +Now that the settings have been updated, you can start the application from the +command line: +```shell +adb shell am start -n "com.google.widevine.fulldecryptpathtesting/.MainActivity" \ + -d "start" +``` + +## Repeatable Tests + +If you want to re-run a test with the exact same test cases, you may change the random seed +in the settings. Each run prints the random seed that it uses in the logcat. +If you don't set the seed, the application picks a random seed using a secure +random number generator. + +## Source Code + +The app builds with Android Studio. + +Vendors who wish to supply their own hashing function instead of using CRC32 +should edit the file HashGenerator.java or the C++ file native-lib.cpp. + +BUILD ERRORS: +If you see the error below you might not have CMake installed. + +```shell +>> * What went wrong: +>> A problem occurred configuring project ':mobile'. +>> > java.lang.NullPointerException (no error message) +``` + +Because the app includes native code written in C++, it requires CMake. From +the tools menu, select Android and Android SDK Manager. On the tab "SDK Tools", +make sure that Cmake is selected. + diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/build.gradle b/libwvdrmengine/test/java/FullDecryptPathTesting/build.gradle new file mode 100644 index 00000000..8d3ef8e5 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/build.gradle @@ -0,0 +1,27 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:3.2.1' + + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/com.google.widevine.fulldecryptpathtesting_preferences.xml b/libwvdrmengine/test/java/FullDecryptPathTesting/com.google.widevine.fulldecryptpathtesting_preferences.xml new file mode 100644 index 00000000..a4c366e4 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/com.google.widevine.fulldecryptpathtesting_preferences.xml @@ -0,0 +1,16 @@ + + + + + default + + + + + 5000 + + + + + + diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/gradle.properties b/libwvdrmengine/test/java/FullDecryptPathTesting/gradle.properties new file mode 100644 index 00000000..aac7c9b4 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/gradle.properties @@ -0,0 +1,17 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/gradle/wrapper/gradle-wrapper.jar b/libwvdrmengine/test/java/FullDecryptPathTesting/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..13372aef Binary files /dev/null and b/libwvdrmengine/test/java/FullDecryptPathTesting/gradle/wrapper/gradle-wrapper.jar differ diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/gradle/wrapper/gradle-wrapper.properties b/libwvdrmengine/test/java/FullDecryptPathTesting/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..29852267 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Jan 29 15:40:42 PST 2019 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/gradlew b/libwvdrmengine/test/java/FullDecryptPathTesting/gradlew new file mode 100755 index 00000000..9d82f789 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/gradlew.bat b/libwvdrmengine/test/java/FullDecryptPathTesting/gradlew.bat new file mode 100644 index 00000000..aec99730 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/.gitignore b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/.gitignore @@ -0,0 +1 @@ +/build diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/CMakeLists.txt b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/CMakeLists.txt new file mode 100644 index 00000000..43f48a21 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/CMakeLists.txt @@ -0,0 +1,46 @@ +# For more information about using CMake with Android Studio, read the +# documentation: https://d.android.com/studio/projects/add-native-code.html + +# Sets the minimum version of CMake required to build the native library. + +cmake_minimum_required(VERSION 3.4.1) + +# Creates and names a library, sets it as either STATIC +# or SHARED, and provides the relative paths to its source code. +# You can define multiple libraries, and CMake builds them for you. +# Gradle automatically packages shared libraries with your APK. + +add_library( # Sets the name of the library. + native-lib + + # Sets the library as a shared library. + SHARED + + # Provides a relative path to your source file(s). + src/main/cpp/native-lib.cpp + src/main/cpp/wvcrc.cpp + ) + +# Searches for a specified prebuilt library and stores the path as a +# variable. Because CMake includes system libraries in the search path by +# default, you only need to specify the name of the public NDK library +# you want to add. CMake verifies that the library exists before +# completing its build. + +find_library( # Sets the name of the path variable. + log-lib + + # Specifies the name of the NDK library that + # you want CMake to locate. + log ) + +# Specifies libraries CMake should link to your target library. You +# can link multiple libraries, such as libraries you define in this +# build script, prebuilt third-party libraries, or system libraries. + +target_link_libraries( # Specifies the target library. + native-lib + + # Links the target library to the log library + # included in the NDK. + ${log-lib} ) diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/build.gradle b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/build.gradle new file mode 100644 index 00000000..b69a8cf7 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/build.gradle @@ -0,0 +1,40 @@ +apply plugin: 'com.android.application' + +android { + compileSdkVersion 28 + defaultConfig { + applicationId "com.google.widevine.fulldecryptpathtesting" + minSdkVersion 24 + targetSdkVersion 29 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + externalNativeBuild { + cmake { + cppFlags "-std=c++14" + } + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation 'com.android.support:appcompat-v7:27.1.1' + implementation 'com.android.support.constraint:constraint-layout:1.1.3' + implementation 'com.android.support:support-v4:26.1.0' + testImplementation 'junit:junit:4.12' + androidTestImplementation 'com.android.support.test:runner:1.0.2' + androidTestImplementation 'com.android.support.test:rules:1.0.2' + androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/proguard-rules.pro b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/androidTest/java/com/google/widevine/fulldecryptpathtesting/FullDecryptPathTest.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/androidTest/java/com/google/widevine/fulldecryptpathtesting/FullDecryptPathTest.java new file mode 100644 index 00000000..b04ff590 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/androidTest/java/com/google/widevine/fulldecryptpathtesting/FullDecryptPathTest.java @@ -0,0 +1,96 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import static org.junit.Assert.*; + +import android.app.Activity; +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; +import android.view.SurfaceView; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Test for the FrameGenerator. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class FullDecryptPathTest { + + @Rule + public final ActivityTestRule mActivityRule = + new ActivityTestRule<>(MainActivity.class); + + @Test + public void initTest() throws Exception { + // Context of the app under test. + Context context = InstrumentationRegistry.getTargetContext(); + + assertEquals("com.google.widevine.fulldecryptpathtesting", context.getPackageName()); + + Logger logger = new Logger(); + FrameGenerator frameGenerator = new FrameGenerator(context, logger); + frameGenerator.prepareKeyFrame(); + assertTrue(0 < frameGenerator.getMinimumFrameSize()); + } + + @Test + public void sizeTest() throws Exception { + // Context of the app under test. + Context context = InstrumentationRegistry.getTargetContext(); + + assertEquals("com.google.widevine.fulldecryptpathtesting", context.getPackageName()); + + Logger logger = new Logger(); + FrameGenerator frameGenerator = new FrameGenerator(context, logger); + frameGenerator.prepareKeyFrame(); + assertTrue(0 < frameGenerator.getMinimumFrameSize()); + int minSize = frameGenerator.getMinimumFrameSize(); + for (int i = 0; i < 1000; i++) { + int size = minSize + i; + ITestFrameBuilder fb = new ByteArrayFrameBuilder(size); + byte[] frame = frameGenerator.buildtestFrame(fb); + assertEquals(size, frame.length); + } + // And one big one: + int size = 4 * 1024 * 1024; + ITestFrameBuilder fb = new ByteArrayFrameBuilder(size); + byte[] frame = frameGenerator.buildtestFrame(fb); + assertEquals(size, frame.length); + } + + @Test + public void testAvcLevel1() throws InterruptedException { + testFullDecryptPath(CodecHandler.MIME_TYPE, true); + } + + @Test + public void testAvcLevel3() throws InterruptedException { + testFullDecryptPath(CodecHandler.MIME_TYPE, false); + } + + private void testFullDecryptPath(String mimeType, boolean useLevel1) + throws InterruptedException { + Logger logger = new Logger(); + Activity activity = mActivityRule.getActivity(); + SurfaceView surfaceView = (SurfaceView) activity.findViewById(R.id.playback_view); + TestParameters parameters = new TestParameters(); + parameters.setUseLevel1(useLevel1); + parameters.setCodecName(MediaUtil.getCodecNameForMime(mimeType, useLevel1)); + TestRunner runner = new TestRunner(logger, activity, surfaceView, parameters); + logger.setActivity(activity); + try { + IWorker worker = new InstrumentationTestWorker(); + runner.doTest(worker); + } finally { + runner.cleanUp(); + } + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/androidTest/java/com/google/widevine/fulldecryptpathtesting/InstrumentationTestWorker.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/androidTest/java/com/google/widevine/fulldecryptpathtesting/InstrumentationTestWorker.java new file mode 100644 index 00000000..fb8bc05d --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/androidTest/java/com/google/widevine/fulldecryptpathtesting/InstrumentationTestWorker.java @@ -0,0 +1,25 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +class InstrumentationTestWorker implements IWorker { + @Override + public void maybePause() {} + + @Override + public boolean finishEarly() { + return false; + } + + @Override + public void handleException(String message, Exception ex) { + throw new RuntimeException(message, ex); + } + + @Override + public void handleError(String message) { + throw new RuntimeException(message); + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/AndroidManifest.xml b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/AndroidManifest.xml new file mode 100644 index 00000000..9e719a41 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + android:supportsRtl="true"> + + + + + + + + + + + + + + diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/cpp/native-lib.cpp b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/cpp/native-lib.cpp new file mode 100644 index 00000000..1726fc00 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/cpp/native-lib.cpp @@ -0,0 +1,34 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +#include +#include +#include +#include + +#include "wvcrc32.h" + +extern "C" JNIEXPORT void JNICALL +Java_com_google_widevine_fulldecryptpathtesting_HashGenerator_computeCRC( + JNIEnv *env, jobject /* this */, jbyteArray jframe, jbyteArray jhash) { + jbyte *frame = env->GetByteArrayElements(jframe, NULL); + jsize frame_size = env->GetArrayLength(jframe); + + jbyte *hash = env->GetByteArrayElements(jhash, NULL); + jsize hash_size = env->GetArrayLength(jhash); + + if (!frame || !hash) { + __android_log_write(ANDROID_LOG_ERROR, "FDPT_CRC", + "Null pointer passed to computeCRC"); + } else if (hash_size != sizeof(uint32_t)) { + __android_log_write(ANDROID_LOG_ERROR, "FDPT_CRC", + "Hash size was not 4 bytes."); + } else { + uint32_t *hash_value = reinterpret_cast(hash); + uint32_t computed_hash = wvoec::wvcrc32n(reinterpret_cast(frame), frame_size); + *hash_value = htonl(computed_hash); + } + if (hash) env->ReleaseByteArrayElements(jhash, hash, 0); + if (frame) env->ReleaseByteArrayElements(jframe, frame, 0); +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/cpp/wvcrc.cpp b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/cpp/wvcrc.cpp new file mode 100644 index 00000000..d7cb8cd5 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/cpp/wvcrc.cpp @@ -0,0 +1,108 @@ +// Copyright 2018 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. +// +// Compute CRC32 Checksum. Needed for verification of WV Keybox. +// +#include "wvcrc32.h" +#include + +namespace wvoec { + +#define INIT_CRC32 0xffffffff + +uint32_t wvrunningcrc32(const uint8_t* p_begin, int i_count, uint32_t i_crc) { + static uint32_t CRC32[256] = { + 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, + 0x130476dc, 0x17c56b6b, 0x1a864db2, 0x1e475005, + 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61, + 0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd, + 0x4c11db70, 0x48d0c6c7, 0x4593e01e, 0x4152fda9, + 0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75, + 0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011, + 0x791d4014, 0x7ddc5da3, 0x709f7b7a, 0x745e66cd, + 0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039, + 0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5, + 0xbe2b5b58, 0xbaea46ef, 0xb7a96036, 0xb3687d81, + 0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d, + 0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49, + 0xc7361b4c, 0xc3f706fb, 0xceb42022, 0xca753d95, + 0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1, + 0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d, + 0x34867077, 0x30476dc0, 0x3d044b19, 0x39c556ae, + 0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072, + 0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16, + 0x018aeb13, 0x054bf6a4, 0x0808d07d, 0x0cc9cdca, + 0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde, + 0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02, + 0x5e9f46bf, 0x5a5e5b08, 0x571d7dd1, 0x53dc6066, + 0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba, + 0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e, + 0xbfa1b04b, 0xbb60adfc, 0xb6238b25, 0xb2e29692, + 0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6, + 0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a, + 0xe0b41de7, 0xe4750050, 0xe9362689, 0xedf73b3e, + 0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2, + 0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686, + 0xd5b88683, 0xd1799b34, 0xdc3abded, 0xd8fba05a, + 0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637, + 0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb, + 0x4f040d56, 0x4bc510e1, 0x46863638, 0x42472b8f, + 0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53, + 0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47, + 0x36194d42, 0x32d850f5, 0x3f9b762c, 0x3b5a6b9b, + 0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff, + 0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623, + 0xf12f560e, 0xf5ee4bb9, 0xf8ad6d60, 0xfc6c70d7, + 0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b, + 0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f, + 0xc423cd6a, 0xc0e2d0dd, 0xcda1f604, 0xc960ebb3, + 0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7, + 0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b, + 0x9b3660c6, 0x9ff77d71, 0x92b45ba8, 0x9675461f, + 0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3, + 0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640, + 0x4e8ee645, 0x4a4ffbf2, 0x470cdd2b, 0x43cdc09c, + 0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8, + 0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24, + 0x119b4be9, 0x155a565e, 0x18197087, 0x1cd86d30, + 0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec, + 0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088, + 0x2497d08d, 0x2056cd3a, 0x2d15ebe3, 0x29d4f654, + 0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0, + 0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c, + 0xe3a1cbc1, 0xe760d676, 0xea23f0af, 0xeee2ed18, + 0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4, + 0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0, + 0x9abc8bd5, 0x9e7d9662, 0x933eb0bb, 0x97ffad0c, + 0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668, + 0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4 + }; + + /* Calculate the CRC */ + while (i_count > 0) { + i_crc = (i_crc << 8) ^ CRC32[(i_crc >> 24) ^ ((uint32_t) * p_begin)]; + p_begin++; + i_count--; + } + + return(i_crc); +} + +uint32_t wvcrc32(const uint8_t* p_begin, int i_count) { + return(wvrunningcrc32(p_begin, i_count, INIT_CRC32)); +} + +uint32_t wvcrc32Init() { + return INIT_CRC32; +} + +uint32_t wvcrc32Cont(const uint8_t* p_begin, int i_count, uint32_t prev_crc) { + return(wvrunningcrc32(p_begin, i_count, prev_crc)); +} + +uint32_t wvcrc32n(const uint8_t* p_begin, int i_count) { + return htonl(wvrunningcrc32(p_begin, i_count, INIT_CRC32)); +} + +} // namespace wvoec diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/cpp/wvcrc32.h b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/cpp/wvcrc32.h new file mode 100644 index 00000000..24362119 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/cpp/wvcrc32.h @@ -0,0 +1,23 @@ +// Copyright 2018 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. +// +// Compute CRC32 Checksum. Needed for verification of WV Keybox. +// +#ifndef CDM_WVCRC32_H_ +#define CDM_WVCRC32_H_ + +#include + +namespace wvoec { + +uint32_t wvcrc32(const uint8_t* p_begin, int i_count); +uint32_t wvcrc32Init(); +uint32_t wvcrc32Cont(const uint8_t* p_begin, int i_count, uint32_t prev_crc); + +// Convert to network byte order +uint32_t wvcrc32n(const uint8_t* p_begin, int i_count); + +} // namespace wvoec + +#endif // CDM_WVCRC32_H_ diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/ic_launcher-web.png b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/ic_launcher-web.png new file mode 100644 index 00000000..c4294efe Binary files /dev/null and b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/ic_launcher-web.png differ diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/ByteArrayFrameBuilder.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/ByteArrayFrameBuilder.java new file mode 100644 index 00000000..58c6d7b5 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/ByteArrayFrameBuilder.java @@ -0,0 +1,48 @@ +package com.google.widevine.fulldecryptpathtesting; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * byte[]-based test frame builder + */ + +public class ByteArrayFrameBuilder implements ITestFrameBuilder { + private final int mSize; + private final ByteArrayOutputStream mBao; + + public ByteArrayFrameBuilder(int size) { + mBao = new ByteArrayOutputStream(size); + mSize = size; + } + + @Override + public int capacity() { + return mSize; + } + + @Override + public void accept(byte[] bytes) { + try { + mBao.write(bytes); + } catch (IOException e) { + throw new RuntimeException("shouldn't happen", e); + } + } + + @Override + public void accept(ByteBuffer bytes) { + throw new UnsupportedOperationException(bytes.toString()); + } + + @Override + public byte[] build() { + return mBao.toByteArray(); + } + + @Override + public ByteBuffer buildBuf() { + throw new UnsupportedOperationException(); + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/ByteBufferFrameBuffer.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/ByteBufferFrameBuffer.java new file mode 100644 index 00000000..0c4caf3c --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/ByteBufferFrameBuffer.java @@ -0,0 +1,40 @@ +package com.google.widevine.fulldecryptpathtesting; + +import java.nio.ByteBuffer; + +/** + * ByteBuffer-based test frame builder + */ + +public class ByteBufferFrameBuffer implements ITestFrameBuilder { + private final ByteBuffer mBuf; + + public ByteBufferFrameBuffer(ByteBuffer buf) { + this.mBuf = buf; + } + + @Override + public int capacity() { + return mBuf.capacity(); + } + + @Override + public void accept(byte[] bytes) { + mBuf.put(bytes); + } + + @Override + public void accept(ByteBuffer bytes) { + mBuf.put(bytes); + } + + @Override + public byte[] build() { + return mBuf.hasArray() ? mBuf.array() : null; + } + + @Override + public ByteBuffer buildBuf() { + return mBuf; + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/CodecHandler.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/CodecHandler.java new file mode 100644 index 00000000..a6f4f038 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/CodecHandler.java @@ -0,0 +1,191 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.media.MediaCodec; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.util.Log; +import android.view.Surface; +import android.view.SurfaceView; +import java.nio.ByteBuffer; + +/** Creates a codec and deals with sending data down to the codec and the widevine plugin. */ +public class CodecHandler { + private static final String TAG = "FDPT_CodecHandler"; + + private Logger mLogger; + private SurfaceView mView; + + private MediaCrypto mCrypto; + private MediaCodec mCodec = null; + + private ByteBuffer mInputBuffer; + private ByteBuffer mOutputBuffer; + private long mPresentationTime = -1; + private MediaFormat mCurrentFormat; + private final IWorker mWorker; + + private static final int LONG_TIME_OUT_US = 2 * 1000 * 1000; // 2 second timeout. + private static final int SHORT_TIME_OUT_US = 2 * 1000; // 2 ms timeout. + private static final int FRAME_RATE = 25; // frames / second. + private static final int FRAME_DELTA = 1000 * 1000 / FRAME_RATE; // usec between frames. + + public static final String MIME_TYPE = "video/avc"; + + CodecHandler( + Logger logger, + MediaCrypto crypto, + SurfaceView view, + MediaFormat format, + IWorker worker) { + mLogger = logger; + mCrypto = crypto; + mView = view; + mCurrentFormat = format; + mWorker = worker; + } + + void createCodecByType(boolean mUseSecureBuffer) { + Log.d(TAG, "createCodecByType secure =" + mUseSecureBuffer); + try { + mCodec = MediaCodec.createDecoderByType(MIME_TYPE); + if (mUseSecureBuffer) { + String defaultName = mCodec.getName(); + String secureName = defaultName + ".secure"; + mCodec = MediaCodec.createByCodecName(secureName); + } + } catch (Exception e) { + mCodec = null; + mWorker.handleException("codec creation", e); + return; + } + mLogger.logInfo("Using codec " + mCodec.getName(), true); + } + + void createCodecByName(String name) { + Log.d(TAG, "createCodecByName name =" + name); + try { + mCodec = MediaCodec.createByCodecName(name); + } catch (Exception e) { + mCodec = null; + mWorker.handleException("codec creation named " + name, e); + return; + } + mLogger.logInfo("Using codec " + mCodec.getName(), true); + } + + boolean start() { + if (mCodec == null) return false; + if (mCrypto == null) return false; + Surface surface = mView.getHolder().getSurface(); + Log.d(TAG, "using surface " + surface); + // Note: when tested, the format is width=1280, height=720, + // durationUs is set -- maybe we need to make it a larger number? + // XXX-- this is probably wrong. We want to add padding to that. + // max-input-size is set to 124410. We might want to increase this to be + // the maximum buffer size we test? + // frame-count = 411. maybe we should change this? + // mime = "video/avc", which agrees with mimeType. + // level = 512. what is this? + // frame-rate = 18. ok? + + // TODO: get max size should be known by test case manager. + // mCurrentFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 200000); + + try { + mCodec.configure(mCurrentFormat, surface, mCrypto, 0 /* decode */); + mCodec.start(); + } catch (Exception ex) { + mWorker.handleException("starting codec.", ex); + return false; + } + return true; + } + + boolean sendData(TestCase test, byte[] clearFrame, byte[] encryptedFrame, boolean lastFrame) { + try { + if (mCodec == null) return false; + + int bufferIndex = mCodec.dequeueInputBuffer(LONG_TIME_OUT_US); + if (bufferIndex == -1) { + mLogger.setError("Could not get input buffer."); + return false; + } + + mInputBuffer = mCodec.getInputBuffer(bufferIndex); + mInputBuffer.put(ByteBuffer.wrap(encryptedFrame)); + + int flags = 0; + // TODO: is this needed if we call flush in finishFrames below? + if (lastFrame) { + flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM; + } + // TODO: how heavily is this inforced? Should we get it from the clock? + long presentationTimeUs = FRAME_DELTA * test.getFrameNumber(); + mCodec.queueSecureInputBuffer( + bufferIndex, 0 /* offset */, test.getCryptoInfo(), presentationTimeUs, flags); + processOutput(); + + } catch (Exception ex) { + mWorker.handleException("Codec issue.", ex); + return false; + } + return true; + } + + private void processOutput() { + try { + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + while (true) { + int outputBufferIndex = mCodec.dequeueOutputBuffer(info, SHORT_TIME_OUT_US); + if (outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { + return; + } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + mCurrentFormat = mCodec.getOutputFormat(); + return; + } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + return; + } else if (outputBufferIndex >= 0) { + mCodec.releaseOutputBuffer(outputBufferIndex, true); + } + } + } catch (Exception ex) { + mWorker.handleException("process output.", ex); + } + } + + void finishFrames() throws InterruptedException { + Log.d(TAG, "finish frames"); + if (mCodec == null) return; + // TODO: clean this up. It should flush all the buffers, but it down't + // need to do much else. + Thread.sleep(2000); // TODO: probably not needed. + Log.d(TAG, "Process output in finishFrames."); + processOutput(); + Log.d(TAG, "calling flush."); + mCodec.flush(); + Log.d(TAG, "Another processOutput."); + processOutput(); + cleanUp(); + Log.d(TAG, "done with finish frames"); + } + + // This can be called multiple times. + void cleanUp() { + try { + Log.d(TAG, "cleanup"); + if (mCodec == null) return; + Log.d(TAG, "codec stop."); + mCodec.stop(); + Log.d(TAG, "codec release."); + mCodec.release(); + mCodec = null; + } catch (Exception ex) { + mWorker.handleException("codec cleanup.", ex); + } + Log.d(TAG, "done with cleanup"); + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/CodecPreference.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/CodecPreference.java new file mode 100644 index 00000000..222c920c --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/CodecPreference.java @@ -0,0 +1,58 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.content.Context; +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.preference.ListPreference; +import android.util.AttributeSet; +import android.util.Log; +import java.util.ArrayList; + +/** + * This is a UI element on the settings/preference screen. It generates a list of codecs from which + * the user can choose. + */ +public class CodecPreference extends ListPreference { + private static final String TAG = "FDPT_CodecPreference"; + + public CodecPreference(Context context, AttributeSet attrs) { + super(context, attrs); + ArrayList entries = new ArrayList(); + ArrayList values = new ArrayList(); + + MediaCodecList list = new MediaCodecList(MediaCodecList.ALL_CODECS); + MediaCodecInfo[] codecInfos = list.getCodecInfos(); + values.add(context.getString(R.string.default_codec)); + entries.add(context.getString(R.string.default_codec_description)); + for (MediaCodecInfo info : codecInfos) { + if (!info.isEncoder()) { + boolean include = false; + StringBuilder sb = new StringBuilder(); + sb.append(info.getName() + ": "); + String[] supportedTypes = info.getSupportedTypes(); + for (String string : supportedTypes) { + if (CodecHandler.MIME_TYPE.equals(string)) include = true; + sb.append(" " + string); + } + if (include) { + values.add(info.getName()); + entries.add(sb.toString()); + Log.d(TAG, sb.toString()); + } + } + } + CharSequence[] array = new CharSequence[entries.size()]; + setEntries(entries.toArray(array)); + setEntryValues(values.toArray(array)); + // TODO: pick best default based on secure or not secure, + // TODO: set value based on current preference value. + } + + public CodecPreference(Context context) { + this(context, null); + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/Encryptor.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/Encryptor.java new file mode 100644 index 00000000..230dac27 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/Encryptor.java @@ -0,0 +1,404 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.media.MediaCodec; +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** Encrypts frames based on the mode and pattern in the test case. */ +public class Encryptor { + private static final String TAG = "FDPT_Encryptor"; + + private LicenseHolder mLicenseHolder; + private Logger mLogger; + + static final int AES_BLOCK_SIZE = 16; + + Encryptor(LicenseHolder licenseHolder, Logger logger) { + this.mLicenseHolder = licenseHolder; + this.mLogger = logger; + } + + byte[] encryptFrame(byte[] frame, TestCase test) { + switch (test.getCryptoInfo().mode) { + default: + mLogger.setError("Unsupported crypto mode: " + test.getCryptoInfo().mode); + return frame; + case MediaCodec.CRYPTO_MODE_UNENCRYPTED: + return frame; + case MediaCodec.CRYPTO_MODE_AES_CTR: + if (test.getEncryptBlocks() > 0) { + return encryptCTRWithPattern(frame, test); + } else { + return encryptCTRNoSkipPattern(frame, test); + } + case MediaCodec.CRYPTO_MODE_AES_CBC: + if (test.getEncryptBlocks() > 0) { + return encryptCBCWithPattern(frame, test); + } else { + return encryptCBCNoSkipPattern(frame, test); + } + } + } + + // Extract the encrypted subsamples into one single buffer. This is used for + // all but "cbcs" mode. + byte[] extractEncryptedSubsamples(final byte[] frame, TestCase test) { + MediaCodec.CryptoInfo cryptoInfo = test.getCryptoInfo(); + int numEncryptedBytes = 0; + int numSubSamples = cryptoInfo.numSubSamples; + for (int i = 0; i < numSubSamples; ++i) { + numEncryptedBytes += cryptoInfo.numBytesOfEncryptedData[i]; + } + byte[] output = new byte[numEncryptedBytes]; + int inputOffset = 0; + int outputOffset = 0; + for (int i = 0; i < numSubSamples; ++i) { + int numClear = cryptoInfo.numBytesOfClearData[i]; + int numEncrypt = cryptoInfo.numBytesOfEncryptedData[i]; + inputOffset += numClear; + System.arraycopy(frame, inputOffset, output, outputOffset, numEncrypt); + inputOffset += numEncrypt; + outputOffset += numEncrypt; + } + return output; + } + + // In the function above, we pulled out the encrypted subsamples. In this function + // we re-assemble the subsamples after the encrypting the single buffer. + byte[] combineSubsamples( + final byte[] frame, // original + final byte[] encrypted, + TestCase test) { + MediaCodec.CryptoInfo cryptoInfo = test.getCryptoInfo(); + byte[] output = new byte[frame.length]; + int numSubSamples = cryptoInfo.numSubSamples; + int frameOffset = 0; + int encryptOffset = 0; + for (int i = 0; i < numSubSamples; ++i) { + int numClear = cryptoInfo.numBytesOfClearData[i]; + int numEncrypt = cryptoInfo.numBytesOfEncryptedData[i]; + System.arraycopy(frame, frameOffset, output, frameOffset, numClear); + frameOffset += numClear; + System.arraycopy(encrypted, encryptOffset, output, frameOffset, numEncrypt); + frameOffset += numEncrypt; + encryptOffset += numEncrypt; + } + return output; + } + + // This takes an IV and resets the low order bytes to 0. This is needed to manually wrap + // the iv on overflow. + byte[] resetIV(byte[] oldIV) { + byte[] iv = new byte[AES_BLOCK_SIZE]; + for (int i = 0; i < 8; i++) { + iv[i] = oldIV[i]; + } + for (int i = 8; i < 16; i++) { + iv[i] = 0; + } + return iv; + } + + // 'CENC' protection scheme. This is counter mode with no pattern. We treat the encrypted + // subsamples as one long block in order to chain the iv. + byte[] encryptCTRNoSkipPattern(final byte[] frame, TestCase test) { + try { + byte[] tempBuffer = extractEncryptedSubsamples(frame, test); + byte[] encrypted = new byte[tempBuffer.length]; + + final Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); + SecretKey key = new SecretKeySpec(mLicenseHolder.getTestKey(), "AES"); + + MediaCodec.CryptoInfo cryptoInfo = test.getCryptoInfo(); + + int bytesEncrypted = 0; + int bytesToEncrypt = encrypted.length; + int bytesToEncryptAfterWrap = 0; + + // First, check to see if the iv will overflow/wrap when we increment it. For the CENC + // standard, we increment the low order 8 bytes as a 64 bit unsigned integer. The + // javax.crypto.Cipher will try to increment the iv as a 128 bit unsigned integer. To + // prevent this, we check to see if the IV would wrap, and break the buffer into two + // chunks if it would wrap. Convert the second batch of bytes to a 64 bit integer + // counter. + byte[] iv = cryptoInfo.iv; + long counter = 0; + for (int i = 8; i < 16; i++) { + counter = (counter << 8) + (iv[i] & 0xFFL); + } + // The 64 bit counter is a signed integer, because that's how Java works. If we pretend + // it is an unsigned integer, then addition overflows or wraps when the high order bit + // changes from 1 to 0. Since Java is doing signed addition, that means |counter| is + // negative, and |endCounter| is positive. + long endCounter = counter + (bytesToEncrypt / AES_BLOCK_SIZE); + if (counter < 0 && endCounter > 0) { + // The first batch to encrypt is -counter -- i.e. how far from the overflow point. + int firstBatch = (int) (-counter); + bytesToEncryptAfterWrap = bytesToEncrypt - firstBatch * AES_BLOCK_SIZE; + bytesToEncrypt = firstBatch * AES_BLOCK_SIZE; + } + + // Loop at most twice to encrypt before the wrap and after the wrap. + while (bytesToEncrypt > 0) { + cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv)); + int result = + cipher.update( + tempBuffer, + bytesEncrypted, + bytesToEncrypt, + encrypted, + bytesEncrypted); + if (bytesToEncrypt != result) { + mLogger.setError( + "encryptCTRNoSkipPattern error: expected=" + + bytesToEncrypt + + " , actual=" + + result); + } + cipher.doFinal(); + + // Increment counters. If there was a wrap, we have to loop back. + bytesEncrypted += bytesToEncrypt; + bytesToEncrypt = bytesToEncryptAfterWrap; + bytesToEncryptAfterWrap = 0; // Ensure we loop at most twice. + iv = resetIV(cryptoInfo.iv); + } + return combineSubsamples(frame, encrypted, test); + } catch (Exception ex) { + mLogger.logException("Encrypt CTR error: ", ex); + } + return frame; + } + + // 'cbc1' protection scheme. This uses CBC with no pattern. We can combine all the + // subsamples and let the normal CBC algorithm chain the IVs. We may also assume that the + // encrypted subsamples end on an AES block boundary. + byte[] encryptCBCNoSkipPattern(final byte[] frame, TestCase test) { + try { + final Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); + SecretKey key = new SecretKeySpec(mLicenseHolder.getTestKey(), "AES"); + cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(test.getCryptoInfo().iv)); + + byte[] tempBuffer = extractEncryptedSubsamples(frame, test); + byte[] encrypted = new byte[tempBuffer.length]; + + int bytesToEncrypt = encrypted.length; + // If a sample does not end on a block boundary, we just copy the + // remainder as unencrypted bytes. + if (bytesToEncrypt % AES_BLOCK_SIZE != 0) { + int remainder = bytesToEncrypt % AES_BLOCK_SIZE; + bytesToEncrypt -= remainder; + System.arraycopy(tempBuffer, bytesToEncrypt, encrypted, bytesToEncrypt, remainder); + } + + int result = cipher.update(tempBuffer, 0, bytesToEncrypt, encrypted, 0); + cipher.doFinal(); + if (result != bytesToEncrypt) { + mLogger.setError( + "encryptCBCNoSkipPattern error: expected=" + + bytesToEncrypt + + " , actual=" + + result); + } + return combineSubsamples(frame, encrypted, test); + } catch (Exception ex) { + mLogger.logException("Encrypt CBC error: ", ex); + } + return frame; + } + + // 'cens' protection scheme. This is CTR with a pattern. All of the test cases that + // we need to support assume that the encrypted subsamples end on an AES block boundary. + byte[] encryptCTRWithPattern(final byte[] frame, TestCase test) { + try { + final Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); + SecretKey key = new SecretKeySpec(mLicenseHolder.getTestKey(), "AES"); + cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(test.getCryptoInfo().iv)); + + byte[] output = new byte[frame.length]; + MediaCodec.CryptoInfo cryptoInfo = test.getCryptoInfo(); + int numSubSamples = cryptoInfo.numSubSamples; + + // This is similar to the code above in encryptCTRNoSkipPattern(), except we increment + // the counter/iv after each pattern. + byte[] iv = cryptoInfo.iv; + long counter = 0; + for (int i = 8; i < 16; i++) { + counter = (counter << 8) + (iv[i] & 0xFFL); + } + + cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv)); + int offset = 0; + for (int i = 0; i < numSubSamples; ++i) { + // clear bytes come first for ISO common encryption + int numClearBytes = cryptoInfo.numBytesOfClearData[i]; + if (numClearBytes > 0) { + System.arraycopy(frame, offset, output, offset, numClearBytes); + offset += numClearBytes; + } + + int numEncryptedBytes = cryptoInfo.numBytesOfEncryptedData[i]; + if (numEncryptedBytes > 0) { + int bytesRemaining = numEncryptedBytes; + while (bytesRemaining > 0) { + int bytesToEncrypt = test.getEncryptBlocks() * AES_BLOCK_SIZE; + int bytesToSkip = + Math.min( + test.getSkipBlocks() * AES_BLOCK_SIZE, + bytesRemaining - bytesToEncrypt); + if (bytesRemaining < bytesToEncrypt) { + // The "cens" spec says that "any partial crypt_byte_block SHALL remain + // unencrypted." We are interpreting this to mean that if the data does + // not have enough bytes to fill all of the blocks in the encrypted part + // of the pattern, then none of the bytes are encrypted. + bytesToEncrypt = 0; + bytesToSkip = bytesRemaining; + } + + // Check to see if the iv will wrap on this chunk. See the comments in + // encryptCTRNoSkipPattern() about counter overflow. + long newCounter = counter + (bytesToEncrypt / AES_BLOCK_SIZE); + int remainder = 0; + boolean overflow = false; + if (counter < 0 && newCounter >= 0) { + overflow = true; + int firstBatch = (int) (-counter); + remainder = bytesToEncrypt - firstBatch * AES_BLOCK_SIZE; + bytesToEncrypt = firstBatch * AES_BLOCK_SIZE; + } + int bytesEncrypted = + cipher.update(frame, offset, bytesToEncrypt, output, offset); + if (bytesEncrypted != bytesToEncrypt) { + mLogger.setError( + "encryptCTRWithPattern error: expected=" + + bytesToEncrypt + + " , actual=" + + bytesEncrypted); + } + offset += bytesToEncrypt; + bytesRemaining -= bytesToEncrypt; + counter = newCounter; + if (overflow) { + cipher.doFinal(); + iv = resetIV(cryptoInfo.iv); + cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv)); + if (remainder > 0) { + int result = + cipher.update(frame, offset, remainder, output, offset); + if (result != remainder) { + mLogger.setError( + "encryptCTRWithPattern error: expected=" + + remainder + + " , actual=" + + result); + } + offset += remainder; + bytesRemaining -= remainder; + } + } + System.arraycopy(frame, offset, output, offset, bytesToSkip); + offset += bytesToSkip; + bytesRemaining -= bytesToSkip; + } + } + } + if (offset != output.length) { + mLogger.setError("cens buffer size: " + output.length + " , actual=" + offset); + } + cipher.doFinal(); + return output; + } catch (Exception ex) { + mLogger.logException("Encrypt CTR error: ", ex); + } + return frame; + } + + // 'cbcs' protection scheme. This is used for HLS. This is CBC with a pattern. + // Unlike the other three schemes, the IV is reset at the start of each subsample. + // If the encrypted subsample does not end on an AES block boundary, we just copy the + // partial remaining block. + byte[] encryptCBCWithPattern(final byte[] frame, TestCase test) { + try { + final Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); + SecretKey key = new SecretKeySpec(mLicenseHolder.getTestKey(), "AES"); + + byte[] output = new byte[frame.length]; + MediaCodec.CryptoInfo cryptoInfo = test.getCryptoInfo(); + int numSubSamples = cryptoInfo.numSubSamples; + int offset = 0; + + // If input length does not align on AES_BLOCK_SIZE, + // we encrypt up to the partial block and leave the partial + // block in the clear. + for (int i = 0; i < numSubSamples; ++i) { + // clear bytes come first for ISO common encryption + int numClearBytes = cryptoInfo.numBytesOfClearData[i]; + if (numClearBytes > 0) { + System.arraycopy(frame, offset, output, offset, numClearBytes); + offset += numClearBytes; + } + + // How many bytes left in this subsample... + int bytesRemaining = cryptoInfo.numBytesOfEncryptedData[i]; + // Except we will handle a final partial block separately, because + // it is never encrypted. + int partialBlock = bytesRemaining % AES_BLOCK_SIZE; + // So, subtract the partial block. + bytesRemaining -= partialBlock; + if (bytesRemaining > 0) { + // resets the IV for each subsample + cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(cryptoInfo.iv)); + while (bytesRemaining > 0) { + int bytesToEncrypt = test.getEncryptBlocks() * AES_BLOCK_SIZE; + int bytesToSkip = + Math.min( + test.getSkipBlocks() * AES_BLOCK_SIZE, + bytesRemaining - bytesToEncrypt); + if (bytesRemaining < bytesToEncrypt) { + // The "cbcs" spec says that "any partial crypt_byte_block SHALL remain + // unencrypted." We are interpreting this to mean that if the data does + // not have enough bytes to fill all of the blocks in the encrypted part + // of the pattern, then none of the bytes are encrypted. + bytesToEncrypt = 0; + bytesToSkip = bytesRemaining; + } + if (bytesToEncrypt > 0) { + int bytesEncrypted = + cipher.update(frame, offset, bytesToEncrypt, output, offset); + if (bytesToEncrypt != bytesEncrypted) { + mLogger.setError( + "encryptCBCWithPattern error: expected=" + + bytesToEncrypt + + " , actual=" + + bytesEncrypted); + } + offset += bytesEncrypted; + bytesRemaining -= bytesEncrypted; + } + if (bytesToSkip > 0) { + System.arraycopy(frame, offset, output, offset, bytesToSkip); + offset += bytesToSkip; + bytesRemaining -= bytesToSkip; + } + } + cipher.doFinal(); + } + if (partialBlock > 0) { + // do not encrypt bytes that are less than block size + System.arraycopy(frame, offset, output, offset, partialBlock); + offset += partialBlock; + } + } + return output; + } catch (Exception ex) { + mLogger.logException("Encrypt CBC error: ", ex); + } + return frame; + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/FDPTApplication.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/FDPTApplication.java new file mode 100644 index 00000000..d1c70b05 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/FDPTApplication.java @@ -0,0 +1,54 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.app.Application; + +public class FDPTApplication extends Application { + + static final String TAG = "FDPT"; + + // Used to load the 'native-lib' library on application startup. + static { + System.loadLibrary("native-lib"); + } + + private Logger mLogger; + private Worker mWorker; + + @Override + public void onCreate() { + super.onCreate(); + mLogger = new Logger(); + mWorker = new Worker(mLogger); + mWorker.start(); + } + + @Override + public void onTerminate() { + super.onTerminate(); + mWorker.onStop(); + } + + @Override + public void onLowMemory() { + super.onLowMemory(); + } + + synchronized void setActivity(MainActivity activity) { + mWorker.onPause(); + mLogger.setActivity(activity); + // TODO: tear down codec's surface and recreate it. This is only + // needed if we restart the activity while a test is already running. + } + + Logger getLogger() { + return mLogger; + } + + Worker getWorker() { + return mWorker; + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/FrameGenerator.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/FrameGenerator.java new file mode 100644 index 00000000..a2067aea --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/FrameGenerator.java @@ -0,0 +1,184 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import android.os.Build; +import android.support.annotation.RequiresApi; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** Generates a valid video key frame. */ +public class FrameGenerator { + private static final String TAG = "FDPT_FrameGenerator"; + + private final int AES_BLOCK_SIZE = 16; + private final int NALU_4_BYTES_START_CODE = 0x0001; + private final int NALU_HEADER_SIZE = 5; // 4 bytes start code + 1 byte type + private final int SEI_MESSAGE_HEADER_SIZE = 2; // 1 byte type and 1 byte size + private final int MAX_SEI_PAYLOAD_SIZE = 255; + private final int MIN_HEXDUMP_BYTES = 32; + + // smallest SEI message has 1 byte payload + private final int MIN_NALU_SIZE = NALU_HEADER_SIZE + SEI_MESSAGE_HEADER_SIZE + 1; + + private Context mContext; + private Logger mLogger; + private MediaFormat mMediaFormat; + private String mMimeType; + private byte[] mKeyFrame; + private int mKeyFrameSize = 0; + + private int getMediaFormatInteger(MediaFormat format, String key) { + return format.containsKey(key) ? format.getInteger(key) : 0; + } + + FrameGenerator(Context context, Logger logger) { + this.mContext = context; + this.mLogger = logger; + } + + @RequiresApi(api = Build.VERSION_CODES.P) + void prepareKeyFrame() { + MediaExtractor extractor = new MediaExtractor(); + mKeyFrameSize = 0; + + try (AssetFileDescriptor afd = mContext.getResources().openRawResourceFd(R.raw.fdpt)) { + extractor.setDataSource(afd); + int tracks = extractor.getTrackCount(); + for (int i = 0; i < tracks; ++i) { + MediaFormat mediaFormat = extractor.getTrackFormat(i); + String mimeType = mediaFormat.getString(MediaFormat.KEY_MIME); + if (mimeType.startsWith("video/")) { + mLogger.logInfo("track #" + i + " " + mediaFormat, true); + mLogger.logInfo( + "Width:" + + getMediaFormatInteger(mediaFormat, MediaFormat.KEY_WIDTH) + + " Height:" + + getMediaFormatInteger(mediaFormat, MediaFormat.KEY_HEIGHT) + + " " + + mimeType, + true); + extractor.selectTrack(i); + } + } + + do { + long sampleSize = extractor.getSampleSize(); + if (sampleSize == -1) { + break; + } else if ((extractor.getSampleFlags() & MediaExtractor.SAMPLE_FLAG_SYNC) != 0) { + ByteBuffer inputBuffer = ByteBuffer.allocate((int) sampleSize); + mKeyFrameSize = extractor.readSampleData(inputBuffer, 0 /*offset*/); + mKeyFrame = new byte[mKeyFrameSize]; + inputBuffer.get(mKeyFrame, 0, mKeyFrameSize); + + mMediaFormat = extractor.getTrackFormat(extractor.getSampleTrackIndex()); + mMimeType = mMediaFormat.getString(MediaFormat.KEY_MIME); + break; + } + } while (extractor.advance()); + + } catch (IOException ioex) { + mLogger.setError("Could not open video source, exception=" + ioex); + } finally { + extractor.release(); + } + } + + byte[] buildtestFrame(ITestFrameBuilder builder) { + return buildtestFrame(builder, builder.capacity()); + } + + // Appends one or more NALU (tail NAL units) at the end of the key frame. + // The NALU is of type 6: Supplemental enhancement information (SEI) + // and will be ignored by the decoder. + byte[] buildtestFrame(ITestFrameBuilder builder, int requestSize) { + final int SEI_MESSAGE_TYPE = 0x66; + final int UNREGISTERED_USER_DATA = 5; + final byte[] DEAD_FEED = new byte[] {(byte) 0xDE, (byte) 0xAD, (byte) 0xFE, (byte) 0xED}; + + int bytesToFill = requestSize - mKeyFrameSize; + int naluSize = MAX_SEI_PAYLOAD_SIZE + NALU_HEADER_SIZE + SEI_MESSAGE_HEADER_SIZE; + final int numNaluNeeded = bytesToFill / naluSize + (bytesToFill % naluSize == 0 ? 0 : 1); + + int totalPayloadSize = + bytesToFill - (NALU_HEADER_SIZE + SEI_MESSAGE_HEADER_SIZE) * numNaluNeeded; + + builder.accept(mKeyFrame); + for (int j = 0; j < numNaluNeeded; ++j) { + int payloadSize = Math.min(MAX_SEI_PAYLOAD_SIZE, totalPayloadSize); + naluSize = NALU_HEADER_SIZE + SEI_MESSAGE_HEADER_SIZE + payloadSize; + totalPayloadSize -= payloadSize; + + // 0x66 (0110 0110) format: + // forbidden_zero_bit (1 bit) = 0 + // nal_ref_idc (2 bits) = 3 + // nal_unit_type (5 bits) = 6 (SEI message) + // SEI message { + // payload type (1 byte) 5 (user data unregistered) + // payload size (1 byte) + // actual message + // } + ByteBuffer nalu = ByteBuffer.allocate(naluSize); + nalu.putInt(NALU_4_BYTES_START_CODE); + nalu.put((byte) SEI_MESSAGE_TYPE); + nalu.put((byte) UNREGISTERED_USER_DATA); + nalu.put((byte) payloadSize); + + for (int i = 0; i < payloadSize; ++i) { + nalu.put(DEAD_FEED[i % DEAD_FEED.length]); + } + + builder.accept(nalu.array()); + + dumpTailNalu(naluSize, nalu, false /* verbose */); + } + return builder.build(); + } + + private void dumpTailNalu(int naluSize, ByteBuffer nalu, boolean verbose) { + if (verbose == false) return; + + mLogger.logInfo("tail NALU size=" + naluSize, true); + mLogger.logInfo( + "tail NALU:\n" + + HexUtil.dumpPrettyHexBytes( + nalu.array(), 0, Math.min(MIN_HEXDUMP_BYTES, naluSize)), + true); + } + + private void dumpPartialFrame(byte[] testFrame, int naluSize, boolean verbose) { + if (verbose == false) return; + + mLogger.logInfo( + "test frame head:\n" + HexUtil.dumpPrettyHexBytes(testFrame, 0, MIN_HEXDUMP_BYTES), + true); + mLogger.logInfo( + "test frame tail:\n" + + HexUtil.dumpPrettyHexBytes( + testFrame, + testFrame.length - naluSize, + Math.min(MIN_HEXDUMP_BYTES, naluSize)), + true); + } + + // TODO: is this needed? + final String getMimeType() { + return mMimeType; + } + + // The minimum frame size is keyFrame plus a smallest NALU for padding. + final int getMinimumFrameSize() { + return mKeyFrame.length + MIN_NALU_SIZE; + } + + final MediaFormat getMediaFormat() { + return mMediaFormat; + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/HashGenerator.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/HashGenerator.java new file mode 100644 index 00000000..d914b574 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/HashGenerator.java @@ -0,0 +1,25 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +/** + * Computes a hash of each frame. This version of HashGenerator uses native C++ code to compute a + * CRC of the frame. Vendors who wish to use a different type of hash or checksum should modify this + * class. You can either write your code in Java, or look in the directory mobile/src/main/cpp and + * modify the native-lib.cpp file. Make sure you update mobile/CMakeLists.txt if you add any new C++ + * source files. + */ +public class HashGenerator { + HashGenerator() {} + + // Compute the hash of one frame. + byte[] getHash(byte[] frame, TestCase test) { + byte[] hash = new byte[4]; // A CRC 32 is 4 bytes long. + computeCRC(frame, hash); + return hash; + } + + public native void computeCRC(byte[] frame, byte[] hash); +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/HexUtil.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/HexUtil.java new file mode 100644 index 00000000..7822d80a --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/HexUtil.java @@ -0,0 +1,69 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +/** + * Dump buffer in hex, 16 bytes per row. + * + * @param inputBuffer buffer to dump + * @param offset index to the first byte to dump + * @param length length of buffer + */ +final class HexUtil { + static String dumpPrettyHexBytes(byte[] inputBuffer, int offset, int length) { + final int NUM_COLUMNS = 16; + + StringBuilder builder = new StringBuilder(); + for (int row = offset; row < offset + length; row += NUM_COLUMNS) { + builder.append(String.format("%06d: ", row)); + + for (int col = 0; col < NUM_COLUMNS; ++col) { + if (row + col < inputBuffer.length) { + builder.append(String.format("%02x ", inputBuffer[row + col])); + } else { + break; + } + } + builder.append(String.format("\n")); + } + + return builder.toString(); + } + + static String dumpHexBytes(byte[] inputBuffer, int offset, int length) { + StringBuilder builder = new StringBuilder(); + for (int i = offset; i < length; i++) { + builder.append(String.format("%02x", inputBuffer[i])); + } + return builder.toString(); + } + + private static byte charsToByte(char left, char right) { + final int leftByte = Character.digit(left, 16) << 4; + final int rightByte = Character.digit(right, 16); + + return (byte) ((leftByte | rightByte) & 0xFF); + } + + public static byte[] fromString(String s) { + final int length = s.length(); + final byte[] data = new byte[length / 2]; + + for (int i = 0; i < length; i += 2) { + data[i / 2] = charsToByte(s.charAt(i), s.charAt(i + 1)); + } + + return data; + } + + public static String toString(byte[] array) { + StringBuilder sb = new StringBuilder(); + + for (byte b : array) { + sb.append(String.format("%02X", b)); + } + return sb.toString(); + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/ITestFrameBuilder.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/ITestFrameBuilder.java new file mode 100644 index 00000000..1363e1e6 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/ITestFrameBuilder.java @@ -0,0 +1,15 @@ +package com.google.widevine.fulldecryptpathtesting; + +import java.nio.ByteBuffer; + +/** + * Interface to build test frame from a key frame and trailing nal units + */ + +public interface ITestFrameBuilder { + int capacity(); + void accept(byte[] bytes); + void accept(ByteBuffer bytes); + byte[] build(); + ByteBuffer buildBuf(); +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/IWorker.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/IWorker.java new file mode 100644 index 00000000..d37a1bf8 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/IWorker.java @@ -0,0 +1,15 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +public interface IWorker { + void maybePause(); + + boolean finishEarly(); + + void handleException(String message, Exception e); + + void handleError(String message); +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/LicenseHolder.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/LicenseHolder.java new file mode 100644 index 00000000..9226485d --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/LicenseHolder.java @@ -0,0 +1,335 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.content.Context; +import android.media.DeniedByServerException; +import android.media.MediaCrypto; +import android.media.MediaCryptoException; +import android.media.MediaDrm; +import android.media.NotProvisionedException; +import android.media.ResourceBusyException; +import android.media.UnsupportedSchemeException; +import android.net.ConnectivityManager; +import android.net.Network; +import java.io.IOException; +import java.util.Arrays; +import java.util.UUID; + +/** Generates a widevine license. If the device needs to be provisioned, it also does that. */ +public class LicenseHolder { + private static final String TAG = "FDPT_LicenseHolder"; + + private Context mContext; + private Logger mLogger; + private boolean mUseLevel1; + + private static final UUID WIDEVINE_UUID = new UUID(0xEDEF8BA979D64ACEL, 0xA3C827DCD51D21EDL); + private static final String ORIGIN_KEY = "origin"; + private static final String ORIGIN = "full-decrypt-path-testing"; + private static final String mServerURL = "https://proxy.uat.widevine.com/proxy"; + private static final String VIDEO_ID = "video_id=FDPT-test-contnt"; + + // A PSSH generated by the integration console, using the key id and content + // id below. This content id is valid on UAT and Widevine staging servers. + private final byte[] FDPT_PSSH = + HexUtil.fromString("2210464450542d746573742d636f6e746e7448e3dc959b06"); + + // Key ID for test content. + static final byte[] TEST_KEY_ID = "FDPT-test-key-id".getBytes(); + // == 464450542d746573742d6b65792d694 in hex. + + // The content id. Used by the license server. + private static final byte[] TEST_CONTENT_ID = "FDPT-test-contnt".getBytes(); + // == 464450542d746573742d636f6e746e74 in hex. + + // The key that is used to decrypt content. + private static final byte[] TEST_KEY_DATA = "0123456789abcdef".getBytes(); + // == 30313233343536373839616263646566 in hex. + + private MediaDrm mDrm = null; + private byte[] mSession; + private byte[] mKeySet; + + private boolean mHasLicense = false; + + LicenseHolder(Logger logger, boolean useLevel1, Context context) { + this.mContext = context; + this.mLogger = logger; + this.mUseLevel1 = useLevel1; + } + + MediaCrypto getMediaCrypto() { + try { + if (!mHasLicense) return null; + return new MediaCrypto(WIDEVINE_UUID, mSession); + } catch (MediaCryptoException ex) { + mLogger.logException("Fetching MediaCrypto", ex); + return null; + } + } + + void cleanUp() { + if (mDrm != null) { + try { + if (mSession != null) { + mDrm.closeSession(mSession); + mSession = null; + } + mDrm.close(); + mDrm = null; + } catch (Exception ex) { + mLogger.logException("License cleanup", ex); + } + } + } + + void init() { + try { + mDrm = new MediaDrm(WIDEVINE_UUID); + } catch (UnsupportedSchemeException ex) { + mLogger.logInfo("Widevine not supported: " + ex, true); + mLogger.setError("Widevine unsupported."); + return; + } + mDrm.setPropertyString(ORIGIN_KEY, ORIGIN); + if (!mUseLevel1) mDrm.setPropertyString("securityLevel", "L3"); + dumpInfo(); + } + + boolean shouldSkipTest() { + if (mDrm == null) { + init(); + if (mDrm == null) return true; + } + int oemCryptoVersion = -1; + String version = mDrm.getPropertyString("oemCryptoApiVersion"); + try { + oemCryptoVersion = Integer.parseInt(version); + } catch (NumberFormatException e) { + mLogger.logException("OEMCrypto Format " + version, e); + return true; + } + if (oemCryptoVersion < 15) { + final CharSequence wrongApi = + "OEMCrypto v15 or greater is required,\n" + + "the device is reporting v" + + oemCryptoVersion + + ".\nSkip all tests."; + mLogger.setError(wrongApi.toString()); + return true; + } + + final String hashSupport = mDrm.getPropertyString("decryptHashSupport"); + if (!"1".equals(hashSupport)) { // OEMCrypto_CRC_Clear_Buffer + final CharSequence noHash = + "Device returns OEMCrypto_Hash_Not_Supported.\n" + "Skip all tests."; + mLogger.setError(noHash.toString()); + return true; + } + return false; + } + + private void dumpInfo() { + mLogger.logInfo(android.os.Build.BRAND + " " + android.os.Build.BOARD, true); + dumpString(R.string.key_security_level, R.string.security_level); + dumpString(R.string.key_system_id, R.string.system_id); + dumpString(R.string.key_cdm_version, R.string.cdm_version); + dumpString(R.string.key_oemcrypto_api, R.string.oemcrypto_api); + dumpString(R.string.key_oemcrypto_build_info, R.string.oemcrypto_build_info); + dumpString(R.string.key_hash_support, R.string.hash_support); + dumpString(R.string.key_resource_rating, R.string.resource_rating); + } + + private void dumpString(int key_index, int name_index) { + String name = mContext.getString(name_index); + String key = mContext.getString(key_index); + try { + mLogger.logInfo(name + ": " + mDrm.getPropertyString(key), true); + } catch (Exception ex) { + mLogger.logInfo(name + ": not defined", true); + } + } + + int getResourceRatingTier() { + String key = mContext.getString(R.string.key_resource_rating); + try { + String value = mDrm.getPropertyString(key); + return Integer.parseInt(value); + } catch (Exception ex) { + mLogger.logInfo(key + ": property not available", true); + return 0; + } + } + + // Fetch and install the license. If provisioning is needed, provision and + // then try to fetch and install again. + void getLicense() { + if (!checkInternet()) return; + try { + if (mDrm == null) { + init(); + } + mSession = mDrm.openSession(); + mLogger.setStatus("Generating license request"); + byte[] initData = FDPT_PSSH; + String mimeType = "cenc"; + String url = mServerURL + "?" + VIDEO_ID; + final MediaDrm.KeyRequest request = + mDrm.getKeyRequest( + mSession, initData, mimeType, MediaDrm.KEY_TYPE_STREAMING, null); + + if (request == null) { + mLogger.setError("Key request not generated."); + return; + } + mLogger.setStatus("Requesting license"); + Post post = new Post(url, request.getData()); + Post.Response response = post.send(); + byte[] data = parseResponseBody(response.body); + mKeySet = mDrm.provideKeyResponse(mSession, data); + if (mKeySet == null) { + mLogger.setError("Empty key set."); + return; + } + mLogger.setStatus("License installed"); + mHasLicense = true; + } catch (NotProvisionedException e) { + if (fetchProvisioning()) { + // Once provisioned, start over. + getLicense(); + } + } catch (ResourceBusyException e) { + mLogger.logInfo("Could not open session: " + e, true); + mLogger.setError("Failed to open session"); + return; + } catch (DeniedByServerException e) { + mLogger.logInfo("License Denied: " + e, true); + mLogger.setError("License denied"); + return; + } catch (SecurityException e) { + // This happens if the app does not have permission to connect to the + // internet. + mLogger.logInfo("Permission failure: " + e, true); + mLogger.setError("security exception: no internet permission?"); + return; + } catch (IOException e) { + mLogger.logInfo("Could not get license: " + e, true); + mLogger.setError("Failed to get license"); + return; + } + } + + private byte[] parseResponseBody(byte[] responseBody) throws IOException { + final String bodyString = new String(responseBody, "UTF-8"); + + if (!bodyString.startsWith("GLS/")) { + return responseBody; + } + if (!bodyString.startsWith("GLS/1.")) { + throw new IOException("Invalid server version, expected 1.x"); + } + final int drmMessageOffset = bodyString.indexOf("\r\n\r\n"); + + if (drmMessageOffset == -1) { + throw new IOException("Invalid server response, could not locate drm message"); + } + return Arrays.copyOfRange(responseBody, drmMessageOffset + 4, responseBody.length); + } + + private boolean fetchProvisioning() { + try { + mLogger.setStatus("Fetching provisioning"); + final MediaDrm.ProvisionRequest request = mDrm.getProvisionRequest(); + final byte[] data = request.getData(); + final String signedUrl = + String.format("%s&signedRequest=%s", request.getDefaultUrl(), new String(data)); + final Post post = new Post(signedUrl, null); + final Post.Response response = post.send(); + mDrm.provideProvisionResponse(response.body); + mLogger.setStatus("Finished provisioning"); + return true; + } catch (SecurityException e) { + // This happens if the app does not have permission to connect to the + // internet. + mLogger.logInfo("Permission failure: " + e, true); + mLogger.setError("security exception: no internet permission?"); + return false; + } catch (Exception ex) { + mLogger.logInfo("Could not provision: " + ex, true); + mLogger.setError("failed to provision"); + return false; + } + } + + boolean hasLicense() { + return mHasLicense; + } + + private boolean checkInternet() { + ConnectivityManager connectivityManager = + (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); + Network network = connectivityManager.getActiveNetwork(); + if (network == null) { + mLogger.setError("No network connected."); + return false; + } + return true; + } + + boolean sendHash(byte[] hash, TestCase test, IWorker worker) { + if (mDrm == null || mSession == null) { + mLogger.setError("Session not open"); + return false; + } + try { + String sSession = new String(mSession); + String value = + sSession + + "," + + test.getFrameNumber() + + "," + + HexUtil.dumpHexBytes(hash, 0, hash.length); + mDrm.setPropertyString("decryptHashSessionId", sSession); + mDrm.setPropertyString("decryptHash", value); + } catch (Exception ex) { + worker.handleException("setting hash", ex); + return false; + } + return true; + } + + boolean foundError(TestCaseFactory factory, IWorker worker) { + try { + String result = mDrm.getPropertyString("decryptHashError"); + String[] csv = result.split(","); + if (csv.length != 2) { + mLogger.setError("Confusing hash error: " + result); + return true; + } + String code = csv[0]; + if (code.equals("0")) { // OEMCrypto_SUCCESS. + return false; + } else if (code.equals("53")) { // OEMCrypto_BAD_HASH + int frameNumber = Integer.parseInt(csv[1]); + int index = frameNumber - 1; + TestCase test = factory.getTest(index); + mLogger.setError(test.getDescription() + " hash wrong"); + test.logInfo(mLogger); + return true; + } else { + mLogger.setError("Confusing hash code: " + result); + } + } catch (Exception ex) { + worker.handleException("checking hash error", ex); + return true; + } + return false; + } + + final byte[] getTestKey() { + return TEST_KEY_DATA; + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/Logger.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/Logger.java new file mode 100644 index 00000000..12139a7a --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/Logger.java @@ -0,0 +1,145 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.app.Activity; +import android.text.Html; +import android.util.Log; +import android.widget.TextView; +import java.text.SimpleDateFormat; +import java.util.Calendar; + +/** + * Logs information to logcat and also important information and errors to a status view in the UI. + */ +class Logger { + private static final String TAG = "FDPT"; + + // The activity to which we post UI tasks. + private Activity mActivity; + + // The text view used for logging. + private TextView mLogView; + private TextView mStatusView; + private StringBuffer mLogBuilder; + private boolean mNewLogLine = true; + + private String mStatusString = null; + private String mErrorString = null; + private int mFrame = -1; + private int mMaximumFrame = 0; + + Logger() { + mLogBuilder = new StringBuffer(); + } + + void setActivity(Activity activity) { + this.mActivity = activity; + this.mLogView = (TextView) mActivity.findViewById(R.id.log_output); + this.mStatusView = (TextView) mActivity.findViewById(R.id.status); + } + + // Only call from UI thread. + void clearLog() { + mLogBuilder = new StringBuffer(); + if (mLogView != null) mLogView.setText(""); + } + + // Log information to the log view and to logcat. + void logInfo(final String s, final boolean end_of_line) { + Log.i(TAG, s); + if (mActivity != null) { + mActivity.runOnUiThread( + new Runnable() { + @Override + public void run() { + if (mNewLogLine) { + String time_stamp = + new SimpleDateFormat("MM-dd HH:mm:ss ") + .format(Calendar.getInstance().getTime()); + mLogBuilder.append(time_stamp); + } + mLogBuilder.append(s); + if (end_of_line) { + mLogBuilder.append("
"); + mNewLogLine = true; + } else { + mLogBuilder.append(", "); + mNewLogLine = false; + } + mLogView.setText(Html.fromHtml(mLogBuilder.toString())); + } + }); + } + } + + void logException(final String s, Exception ex) { + setError(s + " " + ex.getMessage()); + Log.e(TAG, s, ex); + } + + // Log information to the status view, the log view, and to logcat. + // Run on the worker thread. + synchronized void setStatus(String status) { + mStatusString = status; + logInfo("Status Changed: " + mStatusString, true); + updateStatus(); + } + + // Update the current frame number in the counter view. + // Run on the worker thread. + synchronized void setStatus(int currentFrame, int maximumFrame) { + mFrame = currentFrame; + mMaximumFrame = maximumFrame; + Log.i(TAG, "Frame " + (currentFrame + 1) + "/" + maximumFrame); + updateStatus(); + } + + // Log information to the status view, the log view, and to logcat. An error + // will not be replaced by a new status -- only by new errors. + // Run on the worker thread. + synchronized void setError(String errorString) { + if (mErrorString == null) mErrorString = errorString; + logInfo("Error: " + mErrorString, true); + updateStatus(); + } + + synchronized String getErrorString() { + return mErrorString; + } + + // This called by the worker to indicate a new test is starting. + synchronized void clearStatus() { + mFrame = -1; + mMaximumFrame = 0; + mErrorString = null; + mStatusString = ""; + updateStatus(); + } + + synchronized void updateStatus() { + // TODO: don't flood the ui thread with frame number updates. + String status; + if (mErrorString != null) { + status = "" + mErrorString + ""; + } else { + status = mStatusString; + } + if (mMaximumFrame > 0) { + // It looks better to start counting at 1. + status += " Frame " + (mFrame + 1) + "/" + mMaximumFrame + "."; + } + final String newStatus = status; + if (mActivity != null) { + mActivity.runOnUiThread( + new Runnable() { + @Override + public void run() { + mStatusView.setText(Html.fromHtml(newStatus)); + } + }); + } + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/MainActivity.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/MainActivity.java new file mode 100644 index 00000000..3d256864 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/MainActivity.java @@ -0,0 +1,121 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.SurfaceView; +import android.view.View; + +/** This is the main view for the application on phones and tablets. */ +public class MainActivity extends Activity { + + // Used to load the 'native-lib' library on application startup. + static { + System.loadLibrary("native-lib"); + } + + private Logger mLogger; + private Worker mWorker; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + setTitle(R.string.short_app_name); + + FDPTApplication app = (FDPTApplication) getApplicationContext(); + app.setActivity(this); + mLogger = app.getLogger(); + mWorker = app.getWorker(); + + // Note: If you are starting things in batch mode, you might also want to edit + // preferences in batch mode. Use this: + // DIR=/data/data/com.google.widevine.fulldecryptpathtesting/shared_prefs + // FILE=com.google.widevine.fulldecryptpathtesting_preferences.xml + // adb pull $DIR/$FILE + // Then edit $FILE and then + // adb push $FILE $DIR/$FILE + + Intent intent = getIntent(); + String data = intent.getDataString(); + if ("start".equals(intent.getDataString())) { + // For batch mode, use this command to start the tests: + // adb shell am start -n "com.google.widevine.fulldecryptpathtesting/.MainActivity" \ + // -d "start" + startTest(null); + } + if ("stop".equals(intent.getDataString())) { + // For batch mode, use this command to stop the tests: + // adb shell am start -n "com.google.widevine.fulldecryptpathtesting/.MainActivity" \ + // -d "stop" + stopTest(null); + } + } + + @Override + protected void onPause() { + super.onPause(); + mWorker.onPause(); + } + + @Override + protected void onResume() { + super.onResume(); + mWorker.onResume(); + } + + @Override + protected void onDestroy() { + super.onStop(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.action_menu, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.menu_setup) { + configureTest(null); + } + if (item.getItemId() == R.id.menu_play) { + startTest(null); + } + return true; + } + + // Called when Setup button is pressed. + public void configureTest(View view) { + startActivity(new Intent(this, SettingsActivity.class)); + } + + // Called when start test button is pressed. + public void startTest(View view) { + SurfaceView surfaceView = (SurfaceView) findViewById(R.id.playback_view); + Context context = getBaseContext(); + TestParameters parameters = new TestParameters(context, mLogger); + TestRunner runner = new TestRunner(mLogger, context, surfaceView, parameters); + mWorker.startTest(runner); + } + + // Called when stop test button is pressed. + public void stopTest(View view) { + mWorker.cancelTest(); + } + + // Called when clear button is pressed. + public void clearLog(View view) { + mLogger.clearLog(); + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/MediaUtil.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/MediaUtil.java new file mode 100644 index 00000000..9950c0e3 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/MediaUtil.java @@ -0,0 +1,38 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.media.MediaCodecInfo; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCodecList; +import java.util.Arrays; +import java.util.List; + +public final class MediaUtil { + private MediaUtil() {} + + public static String getCodecNameForMime(String mime, boolean secure) { + MediaCodecList mcl = new MediaCodecList(MediaCodecList.ALL_CODECS); + mcl.getCodecInfos(); + for (MediaCodecInfo info : mcl.getCodecInfos()) { + if (info.isEncoder()) { + continue; + } + List supportedTypes = Arrays.asList(info.getSupportedTypes()); + if (!supportedTypes.contains(mime)) { + continue; + } + if (secure) { + CodecCapabilities caps = info.getCapabilitiesForType(mime); + if (caps.isFeatureSupported(CodecCapabilities.FEATURE_SecurePlayback)) { + return info.getName(); + } + } else { + return info.getName(); + } + } + return null; + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/Post.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/Post.java new file mode 100644 index 00000000..a989a9aa --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/Post.java @@ -0,0 +1,189 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.util.Base64; +import android.util.Log; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +/** Post a request to a license or provisioning server and wait for the response. */ +public final class Post { + + private static final int TIMEOUT_MS = 5000; + private static final int MAX_TRIES = 5; + private static final String TAG = "WVPostRequest"; + + static final class Response { + final int code; + final byte[] body; + + Response(int code, byte[] body) { + this.code = code; + this.body = body; + } + } + + private static final byte[] EMPTY_BODY = new byte[0]; + + private final String mUrl; + private final byte[] mData; + private final boolean mExpectOutput; + + private final Map mProperties = new HashMap<>(); + + Post(String url, byte[] data) { + mUrl = url; + mData = data == null ? EMPTY_BODY : Arrays.copyOf(data, data.length); + mExpectOutput = data != null; + setProperty("Accept", "*/*"); + setProperty("User-Agent", "Widevine Full Decrypt Path Test Application"); + setProperty("Connection", "close"); + if (data != null) setProperty("Content-Type", "application/json"); + } + + void setProperty(String key, String value) { + mProperties.put(key, value); + } + + Response send() throws IOException { + + int tries = 1; + boolean needRetry = true; + Response response = null; + + while (needRetry) { + HttpURLConnection connection = null; + needRetry = false; + + try { + + connection = (HttpURLConnection) new URL(mUrl).openConnection(); + + connection.setRequestMethod("POST"); + connection.setDoOutput(mExpectOutput); + connection.setDoInput(true); + connection.setConnectTimeout(TIMEOUT_MS); + connection.setReadTimeout(TIMEOUT_MS); + + for (final Map.Entry property : mProperties.entrySet()) { + connection.setRequestProperty(property.getKey(), property.getValue()); + } + + try (final OutputStream out = connection.getOutputStream()) { + out.write(mData); + } + + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + int connectionResponse = connection.getResponseCode(); + + if (connectionResponse < 400) { + try (final InputStream inputStream = connection.getInputStream()) { + connectStreams(inputStream, outputStream); + } + } else { + try (final InputStream inputStream = connection.getErrorStream()) { + connectStreams(inputStream, outputStream); + } + } + + response = new Response(connection.getResponseCode(), outputStream.toByteArray()); + + // Logging license request / responses + // Catching in try/catch in case other type of request / response that isn't + // loggable + // Provisioning has an empty request and we have no way to decode the + // response at present, so removing it from request / response logging + try { + if (!mUrl.contains("provisioning")) { + String myRequest = Base64.encodeToString(mData, Base64.NO_WRAP); + Log.i("LICENSE_REQUEST:", myRequest); + Log.i("FLUSH", "flushing"); + } + } catch (Exception ex) { + Log.e( + "LICENSE_REQUEST", + "Failure to log licensing request in videoplayer Post.", + ex); + } + + try { + if (!mUrl.contains("provisioning")) { + String myResponse = + Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP); + myResponse = myResponse.equals("") ? "EMPTY" : myResponse; + if (connectionResponse >= 400) { + String s = new String(outputStream.toByteArray(), "UTF-8"); + Log.i("LICENSE_RESPONSE", s); + } else { + Log.i("LICENSE_RESPONSE", myResponse); + } + Log.i("FLUSH", "flushing"); + } + } catch (Exception ex) { + Log.e( + "LICENSE_RESPONSE", + "Failure to log licensing response in videoplayer Post.", + ex); + } + + } catch (SocketTimeoutException ste) { + + if (tries == MAX_TRIES) { + throw ste; + } + + Log.w(TAG, "Retrying after receiving SocketTimeoutException on try " + tries); + tries++; + needRetry = true; + + } catch (Exception ex) { + Log.e(TAG, "Unexpected failure in response / request.", ex); + + } finally { + + if (connection != null) { + connection.disconnect(); + } + } + } + if (response == null) { + throw new IOException("Empty response"); + } + if (response.code != 200) { + throw new IOException("Server returned HTTP error code " + response.code); + } + if (response.body == null) { + throw new IOException("No response from server"); + } + if (response.body.length == 0) { + throw new IOException("Empty response from server"); + } + return response; + } + + private static void connectStreams(InputStream in, OutputStream out) throws IOException { + + final byte scratch[] = new byte[1024]; + + int read; /* declare this here so that the for loop can be aligned */ + + for (read = in.read(scratch, 0, scratch.length); + read != -1; + read = in.read(scratch, 0, scratch.length)) { + + out.write(scratch, 0, read); + } + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/SettingsActivity.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/SettingsActivity.java new file mode 100644 index 00000000..c463ed10 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/SettingsActivity.java @@ -0,0 +1,33 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.os.Bundle; +import android.preference.PreferenceActivity; +import android.preference.PreferenceFragment; + +// This is the UI for the settings or preferences. +// TODO: when the user changes L1/L3, pick a different default codec. +// TODO: pick a good default codec. +public class SettingsActivity extends PreferenceActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getFragmentManager() + .beginTransaction() + .replace(android.R.id.content, new MySettingsFragment()) + .commit(); + } + + public static class MySettingsFragment extends PreferenceFragment { + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // Read the list of preferences that the user can change from the file + // mobile/src/main/res/xml/preferences.xml. + addPreferencesFromResource(R.xml.preferences); + } + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/TestCase.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/TestCase.java new file mode 100644 index 00000000..bbe4c765 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/TestCase.java @@ -0,0 +1,96 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.media.MediaCodec; + +// A single test case. +class TestCase { + private String mDescription; + + // The size of the frame buffer. + private int mBufferSize; + + // The frame number. Used for documenting problems. + private int mFrameNumber; + + private MediaCodec.CryptoInfo.Pattern mPattern; + + // The encryption pattern and mode. + private MediaCodec.CryptoInfo mCryptoInfo; + + TestCase( + int frameNumber, + String description, + int bufferSize, + MediaCodec.CryptoInfo cryptoInfo, + MediaCodec.CryptoInfo.Pattern pattern) { + this.mDescription = description; + this.mBufferSize = bufferSize; + this.mPattern = pattern; + this.mCryptoInfo = cryptoInfo; + this.mFrameNumber = frameNumber; + } + + public String toString() { + return "Frame: " + + mFrameNumber + + ", Buffer size: " + + mBufferSize + + ", Pattern = " + + mPattern + + ", Crypto: " + + mCryptoInfo; + } + + public void logInfo(Logger logger) { + logger.logInfo("Frame " + mFrameNumber + ": " + mDescription, true); + StringBuilder sizes = new StringBuilder("size = ["); + for (int i = 0; i < mCryptoInfo.numSubSamples; i++) { + sizes.append(" ("); + sizes.append(mCryptoInfo.numBytesOfClearData[i]); + sizes.append(", "); + sizes.append(mCryptoInfo.numBytesOfEncryptedData[i]); + sizes.append(");"); + } + sizes.append("] total="); + sizes.append(mBufferSize); + logger.logInfo(sizes.toString(), true); + String mode = + (mCryptoInfo.mode == MediaCodec.CRYPTO_MODE_AES_CTR) ? "Mode = CTR" : "Mode = CBC"; + logger.logInfo( + mode + + ", Pattern = (e=" + + mPattern.getEncryptBlocks() + + ", s=" + + mPattern.getSkipBlocks() + + ")", + true); + } + + final MediaCodec.CryptoInfo getCryptoInfo() { + return mCryptoInfo; + } + + final int getFrameNumber() { + return mFrameNumber; + } + + final int getBufferSize() { + return mBufferSize; + } + + final int getEncryptBlocks() { + return mPattern.getEncryptBlocks(); + } + + final int getSkipBlocks() { + return mPattern.getSkipBlocks(); + } + + final String getDescription() { + return mDescription; + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/TestCaseFactory.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/TestCaseFactory.java new file mode 100644 index 00000000..c2501ec3 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/TestCaseFactory.java @@ -0,0 +1,404 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.media.MediaCodec; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Random; + +// Generates a list of test cases. It uses preferences to pick the test cases. +public class TestCaseFactory { + + private static final String TAG = "FDPT_TestCaseFactory"; + + private Logger mLogger; + private Random mRandom; + private ArrayList mTests = new ArrayList(); + private int mMinSize; + private TestParameters mParameters; + private int mRRTier; + + static final int AES_BLOCK_SIZE = 16; + + TestCaseFactory(Logger logger, int minSize, int resourceRatingTier, TestParameters parameters) { + this.mLogger = logger; + this.mMinSize = minSize; + this.mParameters = parameters; + this.mRRTier = resourceRatingTier; + + if (minSize <= 0) { + mLogger.setError("Frame Generator failed."); + return; + } + + int seed = mParameters.getRandomSeed(); + if (seed == 0) { + SecureRandom starter = new SecureRandom(); + seed = starter.nextInt(); + } + mLogger.logInfo("Picking random seed " + seed, true); + mRandom = new Random(seed); + + if (mParameters.getTestCENC()) buildCENCTests(); + if (mParameters.getTestCENS()) buildCENSTests(); + if (mParameters.getTestCBC1()) buildCBC1Tests(); + if (mParameters.getTestCBCS()) buildCBCSTests(); + } + + // The maximum number of subsamples required for the given resource rating tier. + int maxSubsamples() { + switch (mRRTier) { + default: + case 1: + return 10; + case 2: + return 16; + case 3: + return 32; + } + } + + // The maximum sample size required for the given resource rating tier. + int maxSampleSize() { + int MB = 1024 * 1024; + switch (mRRTier) { + default: + case 1: + return 1 * MB; + case 2: + return 2 * MB; + case 3: + return 4 * MB; + } + } + + // The maximum subsample size required for the given resource rating tier. + int maxSubsampleSize() { + int KB = 1024; + int MB = 1024 * 1024; + switch (mRRTier) { + default: + case 1: + return 100 * KB; + case 2: + return 500 * KB; + case 3: + return 1 * MB; + } + } + + // Round up the given number to a multiple of pad. + final int roundUp(int originalSize, int pad) { + if (0 == pad) { + pad = AES_BLOCK_SIZE; + } + if ((originalSize % pad) > 0) { + return (originalSize + (pad - (originalSize % pad))); + } else { + return originalSize; + } + } + + // Generate a random integer between 0 and max. + final int randomInt(int max) { + int x = mRandom.nextInt(); + if (x < 0) x = -x; + return x % max; + } + + // Decide if a pattern and mode combination should be tested or not and return true if the test + // should be skipped. + boolean forbidsPartialBlocks(MediaCodec.CryptoInfo.Pattern pattern, int mode) { + // By the CENC spec ISO/IEC 23001-7:2016, in sections 9.5.2.3 and 9.5.2.5, some modes and + // patterns require subsamples to end on an AES block boundary. + // (Thanks, Juce for reading the spec and figuring out which tests we can dump!) + // For "cens", which is CTR with a pattern, subsamples should end on a block boundary, + // so we throw out those tests. It is not worth testing the other case. The CENC spec + // says applications may prohibit these. + if (mode == MediaCodec.CRYPTO_MODE_AES_CTR && pattern.getEncryptBlocks() > 0) { + return true; + } + // Similarly, in "cbc1", subsamples SHALL end on a block boundary, so we throw out + // all those tests. + if (mode == MediaCodec.CRYPTO_MODE_AES_CBC && pattern.getEncryptBlocks() == 0) { + return true; + } + return false; + } + + private void buildCENCTests() { + String description = "cenc"; + MediaCodec.CryptoInfo.Pattern pattern = new MediaCodec.CryptoInfo.Pattern(0, 0); + int mode = MediaCodec.CRYPTO_MODE_AES_CTR; + buildTestsForMode(description, pattern, mode); + if (mParameters.getTestWrap()) { + buildWrappingIVTest(description, pattern, mode); + } + } + + private void buildCENSTests() { + String description = "cens"; + MediaCodec.CryptoInfo.Pattern pattern = new MediaCodec.CryptoInfo.Pattern(1, 9); + int mode = MediaCodec.CRYPTO_MODE_AES_CTR; + buildEncTests(description, pattern, mode); + buildTestsForModeRandomSizes(description, pattern, mode); + if (mParameters.getTestWrap()) { + buildWrappingIVTest(description, pattern, mode); + } + } + + private void buildCBC1Tests() { + String description = "cbc1"; + MediaCodec.CryptoInfo.Pattern pattern = new MediaCodec.CryptoInfo.Pattern(0, 0); + int mode = MediaCodec.CRYPTO_MODE_AES_CBC; + buildEncTests(description, pattern, mode); + buildTestsForModeRandomSizes(description, pattern, mode); + } + + private void buildCBCSTests() { + String description = "cbcs"; + MediaCodec.CryptoInfo.Pattern pattern = new MediaCodec.CryptoInfo.Pattern(1, 9); + int mode = MediaCodec.CRYPTO_MODE_AES_CBC; + buildTestsForMode(description, pattern, mode); + } + + // Build tests for the given mode and skip pattern by generating a variety of sizes. + private void buildTestsForMode( + String description, MediaCodec.CryptoInfo.Pattern pattern, int mode) { + buildTestsForModeSpecialSizes(description, pattern, mode); + buildTestsForModeRandomSizes(description, pattern, mode); + } + + // Build some tests that have edge cases as the subsample sizes. These tests are + // also in the OEMCrypto unit tests. + private void buildTestsForModeSpecialSizes( + String description, MediaCodec.CryptoInfo.Pattern pattern, int mode) { + + // Note: The first test case has to have some encryption so that + // the CDM layer calls SelectKey before we try to compute a hash. + // That is because we don't necessarily compute a hash for CopyBuffer. + buildEncTests(description, pattern, mode); + buildClearTests(description, pattern, mode); + + int[] pads = {0, 10, 25, 32}; + for (int k = 0; k < pads.length; k++) { + buildNoOffsetTests(description, pattern, mode, pads[k]); + } + for (int k = 0; k < pads.length; k++) { + buildEvenOffsetTests(description, pattern, mode, pads[k]); + } + for (int k = 0; k < pads.length; k++) { + buildOddOffsetTests(description, pattern, mode, pads[k]); + } + buildPartialBlockTests(description, pattern, mode); + buildSmallSubsampleTests(description, pattern, mode); + if (mParameters.getTestMax()) { + buildMaxSampleTests(description, pattern, mode); + buildMaxSubSampleTests(description, pattern, mode); + } + } + + // This tests the ability to decrypt multiple subsamples with no offset. + // There is no offset within the block, used by CTR mode. However, there might + // be an offset in the encrypt/skip pattern. + private void buildNoOffsetTests( + String description, MediaCodec.CryptoInfo.Pattern pattern, int mode, int pad) { + int[] clearSizes = {pad, pad, pad}; + int[] encryptedSizes = {48, 64, roundUp(mMinSize, 16)}; + buildTestsForModeAndSizes( + description + ", no offset", pattern, mode, clearSizes, encryptedSizes); + } + + // This tests an offset into the block for the second encrypted subsample. + // This should only work for CTR mode, for CBC mode an error is expected in + // the decrypt step. + // If this test fails for CTR mode, then it is probably handling the + // block_offset incorrectly. + private void buildEvenOffsetTests( + String description, MediaCodec.CryptoInfo.Pattern pattern, int mode, int pad) { + int[] clearSizes = {pad, pad, pad}; + int[] encryptedSizes = {16 + 8, 32, roundUp(mMinSize, 16)}; + buildTestsForModeAndSizes( + description + ", even offset", pattern, mode, clearSizes, encryptedSizes); + } + + // If the EvenOffset test passes, but this one doesn't, then DecryptCTR might + // be using the wrong definition of block offset. Adding the block offset to + // the block boundary should give you the beginning of the encrypted data. + // This should only work for CTR mode, for CBC mode, the block offset must be + // 0, so an error is expected in the decrypt step. + // Another way to view the block offset is with the formula: + // block_boundary + block_offset = beginning of subsample. + private void buildOddOffsetTests( + String description, MediaCodec.CryptoInfo.Pattern pattern, int mode, int pad) { + int[] clearSizes = {pad, pad, pad}; + int[] encryptedSizes = {50, 75, roundUp(mMinSize, 16)}; + buildTestsForModeAndSizes( + description + ", odd offset", pattern, mode, clearSizes, encryptedSizes); + } + + // This tests the case where an encrypted sample is not an even number of + // blocks. For CTR mode, the partial block is encrypted. For CBC mode the + // partial block should be a copy of the clear data. + private void buildPartialBlockTests( + String description, MediaCodec.CryptoInfo.Pattern pattern, int mode) { + int[] clearSizes = {mMinSize}; + int[] encryptedSizes = {50}; + buildTestsForModeAndSizes( + description + ", partial block", pattern, mode, clearSizes, encryptedSizes); + } + + // A small subsample. + private void buildSmallSubsampleTests( + String description, MediaCodec.CryptoInfo.Pattern pattern, int mode) { + int[] clearSizes = {5, mMinSize}; + int[] encryptedSizes = {5, 0}; + buildTestsForModeAndSizes( + description + ", small subsample", pattern, mode, clearSizes, encryptedSizes); + } + + // The whole sample is clear. + private void buildClearTests( + String description, MediaCodec.CryptoInfo.Pattern pattern, int mode) { + int[] clearSizes = {mMinSize}; + int[] encryptedSizes = {0}; + buildTestsForModeAndSizes( + description + ", all clear", pattern, mode, clearSizes, encryptedSizes); + } + + // The whole sample is encrypted. + private void buildEncTests( + String description, MediaCodec.CryptoInfo.Pattern pattern, int mode) { + int[] clearSizes = {0}; + int[] encryptedSizes = {roundUp(mMinSize, 16)}; + buildTestsForModeAndSizes( + description + ", all encrypted", pattern, mode, clearSizes, encryptedSizes); + } + + // A test with maximum sample size. + private void buildMaxSampleTests( + String description, MediaCodec.CryptoInfo.Pattern pattern, int mode) { + int subsize = maxSampleSize() / maxSubsamples(); + int[] clearSizes = new int[maxSubsamples()]; + int[] encryptedSizes = new int[maxSubsamples()]; + for (int i = 0; i < maxSubsamples(); i++) { + clearSizes[i] = 0; + encryptedSizes[i] = subsize; + } + buildTestsForModeAndSizes( + description + ", max sample", pattern, mode, clearSizes, encryptedSizes); + } + + // A test with maximum subsample size. + private void buildMaxSubSampleTests( + String description, MediaCodec.CryptoInfo.Pattern pattern, int mode) { + int count = maxSampleSize() / maxSubsampleSize(); + int[] clearSizes = new int[count]; + int[] encryptedSizes = new int[count]; + for (int i = 0; i < count; i++) { + clearSizes[i] = 0; + encryptedSizes[i] = maxSubsampleSize(); + } + buildTestsForModeAndSizes( + description + ", max subsample", pattern, mode, clearSizes, encryptedSizes); + } + + // Build a bunch of randomly sized buffers for testing different sizes. The + // user can select how many tests to run in the settings screen. + private void buildTestsForModeRandomSizes( + String description, MediaCodec.CryptoInfo.Pattern pattern, int mode) { + int count = mParameters.getRandomCount(); + boolean doRounding = forbidsPartialBlocks(pattern, mode); + for (int t = 0; t < count; t++) { + int numSubsamples = 1 + randomInt(maxSubsamples()); + if (doRounding && !mParameters.getTestMultiSubsample()) { + // For the modes that require subsamples end on a block boundary, + // multisubsamples is rarely used. We don't run those tests + // unless explicitly requested. + numSubsamples = 1; + } + // Split the smallest into even pieces, and round up. + int base_size = mMinSize / numSubsamples + 1; + int[] clearSizes = new int[numSubsamples]; + int[] encryptedSizes = new int[numSubsamples]; + for (int i = 0; i < numSubsamples; i++) { + clearSizes[i] = randomInt(100); + encryptedSizes[i] = base_size + randomInt(600); + if (doRounding) { + encryptedSizes[i] = roundUp(encryptedSizes[i], AES_BLOCK_SIZE); + } + } + buildTestsForModeAndSizes(description, pattern, mode, clearSizes, encryptedSizes); + } + } + + private boolean buildTestsForModeAndSizes( + String description, + MediaCodec.CryptoInfo.Pattern pattern, + int mode, + int[] clearSizes, + int[] encryptedSizes) { + // Throw out tests that are forbidden. + if (forbidsPartialBlocks(pattern, mode)) { + for (int i = 0; i < encryptedSizes.length; i++) { + if (encryptedSizes[i] % AES_BLOCK_SIZE != 0) return false; + } + } + // Test with a random iv. + byte[] iv = new byte[16]; + mRandom.nextBytes(iv); + addTest(description, pattern, mode, clearSizes, encryptedSizes, iv); + return true; + } + + // This is used to test that the IV wraps correctly in CTR mode. + private void buildWrappingIVTest( + String description, MediaCodec.CryptoInfo.Pattern pattern, int mode) { + int[] clearSizes = {0}; + int[] encryptedSizes = {roundUp(mMinSize, AES_BLOCK_SIZE)}; + byte[] iv = new byte[16]; + mRandom.nextBytes(iv); + // Set the last 8 bytes to be near the wrap value: + for (int i = 8; i < 16; i++) iv[i] = (byte) 0xFF; + iv[15] = (byte) 0xFE; + addTest(description + ", wrapping iv", pattern, mode, clearSizes, encryptedSizes, iv); + } + + private void addTest( + String description, + MediaCodec.CryptoInfo.Pattern pattern, + int mode, + int[] clearSizes, + int[] encryptedSizes, + byte[] iv) { + // Start counting frame number at 1, for human readability. + int frameNumber = mTests.size() + 1; + if (clearSizes.length != encryptedSizes.length) { + mLogger.setError("Malformed Test " + frameNumber); + return; + } + int testFrameSize = 0; + for (int i = 0; i < clearSizes.length; i++) { + testFrameSize += clearSizes[i]; + testFrameSize += encryptedSizes[i]; + } + MediaCodec.CryptoInfo info = new MediaCodec.CryptoInfo(); + info.set( + clearSizes.length, clearSizes, encryptedSizes, LicenseHolder.TEST_KEY_ID, iv, mode); + info.setPattern(pattern); + mTests.add(new TestCase(frameNumber, description, testFrameSize, info, pattern)); + } + + final int getTestCount() { + return mTests.size(); + } + + // Get the test case for the specified index. This can be called + // multiple times for each frame. + final TestCase getTest(int index) { + return mTests.get(index); + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/TestParameters.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/TestParameters.java new file mode 100644 index 00000000..2649280e --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/TestParameters.java @@ -0,0 +1,191 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +class TestParameters { + private String mCodecName; + private boolean mUseLevel1 = false; + private boolean mTestCENC = true; + private boolean mTestCENS = false; + private boolean mTestCBCS = false; + private boolean mTestCBC1 = false; + private boolean mTestMax = false; + private boolean mTestWrap = false; + // TODO(b/139257871): This defaults to false until iv incrementing is corrected for cbc1. + private boolean mTestMultiSubsample = false; + private boolean mLogEachFrame = false; + private int mRandomCount = 5; + private int mRandomSeed = 0; + private int mTestFrame = -1; + + // Create a minimal set of tests. + TestParameters() { + mCodecName = "default"; + } + + TestParameters(Context context, Logger logger) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + mCodecName = getCodecName(context, preferences); + mUseLevel1 = preferences.getBoolean(context.getString(R.string.key_use_level_1), true); + mTestCENC = preferences.getBoolean(context.getString(R.string.key_test_cenc), true); + mTestCENS = preferences.getBoolean(context.getString(R.string.key_test_cens), true); + mTestCBC1 = preferences.getBoolean(context.getString(R.string.key_test_cbc1), true); + mTestCBCS = preferences.getBoolean(context.getString(R.string.key_test_cbcs), true); + mTestMax = preferences.getBoolean(context.getString(R.string.key_test_max), false); + mTestWrap = preferences.getBoolean(context.getString(R.string.key_test_wrap), true); + mTestMultiSubsample = + preferences.getBoolean(context.getString(R.string.key_test_multi_subsample), false); + mLogEachFrame = + preferences.getBoolean(context.getString(R.string.key_log_each_frame), false); + String value = + preferences.getString(context.getString(R.string.key_random_test_count), "5000"); + try { + mRandomCount = Integer.parseInt(value); + } catch (NumberFormatException e) { + logger.logException("Number format for preference " + value, e); + mRandomCount = 1000; + } + + value = preferences.getString(context.getString(R.string.key_random_seed), "0"); + try { + mRandomSeed = Integer.parseInt(value); + } catch (NumberFormatException e) { + mRandomSeed = 0; // Use 0 if string is empty or malformed. + } + value = preferences.getString(context.getString(R.string.key_test_frame), "0"); + try { + mTestFrame = Integer.parseInt(value); + } catch (NumberFormatException e) { + mTestFrame = 0; // Use 0 if string is empty or malformed. + } + } + + private String getCodecName(Context context, SharedPreferences preferences) { + // The user can specify a particular codec on the settings screen. If + // they leave it as default. Just to be clear, the default value is + // "default" and means we should get the codec by type instead of by + // name. + String codecName = + preferences.getString( + context.getString(R.string.key_codec), + context.getString(R.string.default_codec)); + if (codecName == null + || codecName.equals(context.getString(R.string.default_codec)) + || "".equals(codecName)) { + boolean secureCodec = + preferences.getBoolean(context.getString(R.string.key_use_secure_buffer), true); + codecName = MediaUtil.getCodecNameForMime(CodecHandler.MIME_TYPE, secureCodec); + } + return codecName; + } + + public String getCodecName() { + return mCodecName; + } + + public void setCodecName(String codecName) { + this.mCodecName = mCodecName; + } + + public boolean getUseLevel1() { + return mUseLevel1; + } + + public void setUseLevel1(boolean useLevel1) { + this.mUseLevel1 = useLevel1; + } + + public boolean getTestCENC() { + return mTestCENC; + } + + public void setTestCENC(boolean testCENC) { + this.mTestCENC = testCENC; + } + + public boolean getTestCENS() { + return mTestCENS; + } + + public void setTestCENS(boolean testCENS) { + this.mTestCENS = testCENS; + } + + public boolean getTestCBCS() { + return mTestCBCS; + } + + public void setTestCBCS(boolean testCBCS) { + this.mTestCBCS = testCBCS; + } + + public boolean getTestCBC1() { + return mTestCBC1; + } + + public void setTestCBC1(boolean testCBC1) { + this.mTestCBC1 = testCBC1; + } + + public boolean getTestMax() { + return mTestMax; + } + + public void setTestMax(boolean testMax) { + this.mTestMax = testMax; + } + + public boolean getTestWrap() { + return mTestWrap; + } + + public void setTestWrap(boolean testWrap) { + this.mTestWrap = testWrap; + } + + public boolean getTestMultiSubsample() { + return mTestMultiSubsample; + } + + public void setTestMultiSubsample(boolean testMultiSubsample) { + this.mTestMultiSubsample = testMultiSubsample; + } + + public boolean getLogEachFrame() { + return mLogEachFrame; + } + + public void setLogEachFrame(boolean logEachFrame) { + this.mLogEachFrame = logEachFrame; + } + + public int getRandomCount() { + return mRandomCount; + } + + public void setRandomCount(int randomCount) { + this.mRandomCount = mRandomCount; + } + + public int getRandomSeed() { + return mRandomSeed; + } + + public void setRandomSeed(int randomCount) { + this.mRandomSeed = mRandomSeed; + } + + public int getTestFrame() { + return mTestFrame; + } + + public void setTestFrame(int randomCount) { + this.mTestFrame = mTestFrame; + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/TestRunner.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/TestRunner.java new file mode 100644 index 00000000..f28226fb --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/TestRunner.java @@ -0,0 +1,158 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +import android.content.Context; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.view.SurfaceView; + +// Helps initialize the other objects used in running the tests. +public class TestRunner { + + private static final String TAG = "FDPT_TestRunner"; + + private Logger mLogger; + private Context mContext; + private SurfaceView mView; + + private LicenseHolder mLicenseHolder; + private CodecHandler mCodecHandler; + private TestParameters mParameters; + + TestRunner(Logger logger, Context context, SurfaceView view, TestParameters parameters) { + this.mLogger = logger; + this.mContext = context; + this.mView = view; + this.mParameters = parameters; + } + + void cleanUp() { + if (mCodecHandler != null) { + mCodecHandler.cleanUp(); + } + if (mLicenseHolder != null) { + mLicenseHolder.cleanUp(); + } + } + + boolean initLicense() { + mLicenseHolder = new LicenseHolder(mLogger, mParameters.getUseLevel1(), mContext); + if (mLicenseHolder.shouldSkipTest()) return false; + mLicenseHolder.getLicense(); + if (!mLicenseHolder.hasLicense()) return false; + return true; + } + + boolean initCodecHandler(MediaFormat format, IWorker worker) { + MediaCrypto crypto = mLicenseHolder.getMediaCrypto(); + if (crypto == null) return false; + + mCodecHandler = new CodecHandler(mLogger, crypto, mView, format, worker); + + mCodecHandler.createCodecByName(mParameters.getCodecName()); + if (!mCodecHandler.start()) return false; + return true; + } + + // Run by the worker thread to do one test. + void doTest(IWorker worker) throws InterruptedException { + if (worker.finishEarly()) return; + mLogger.clearStatus(); + mLogger.setStatus("Start test."); + + if (!initLicense()) return; + + FrameGenerator frameGenerator = new FrameGenerator(mContext, mLogger); + if (frameGenerator == null) return; + frameGenerator.prepareKeyFrame(); + + HashGenerator hashGenerator = new HashGenerator(); + + Encryptor encryptor = new Encryptor(mLicenseHolder, mLogger); + + if (!initCodecHandler(frameGenerator.getMediaFormat(), worker)) return; + mLogger.setStatus("Codec ready."); + + int minSize = frameGenerator.getMinimumFrameSize(); + if (minSize <= 0) return; + int tier = mLicenseHolder.getResourceRatingTier(); + TestCaseFactory mTestCaseFactory = + new TestCaseFactory( + mLogger, minSize, + tier, mParameters); + + int testCount = mTestCaseFactory.getTestCount(); + mLogger.setStatus("Running"); + String currentDescription = "init"; + mLogger.setStatus(-1, testCount); // Indicate no tests have passed. + if (testCount < 1) { + mLogger.setStatus("No test cases selected."); + mLogger.logInfo("Go to Setup page and select tests before clicking 'Start Test'", true); + return; + } + for (int i = 0; i < testCount; i++) { + worker.maybePause(); + if (worker.finishEarly()) return; + TestCase test = mTestCaseFactory.getTest(i); + if (!currentDescription.equals(test.getDescription())) { + currentDescription = test.getDescription(); + mLogger.setStatus("Testing " + currentDescription); + } + if (mParameters.getLogEachFrame()) { + test.logInfo(mLogger); + } + ByteArrayFrameBuilder fb = new ByteArrayFrameBuilder(test.getBufferSize()); + byte[] clearFrame = frameGenerator.buildtestFrame(fb); + if (clearFrame == null) { + mLogger.setError("Frame generation failed."); + test.logInfo(mLogger); + return; + } + if (clearFrame.length != test.getBufferSize()) { + mLogger.setError( + "Frame generation size = " + + clearFrame.length + + ", expected " + + test.getBufferSize()); + test.logInfo(mLogger); + return; + } + byte[] hash = hashGenerator.getHash(clearFrame, test); + if (hash == null) { + mLogger.setError("Hash generation failed."); + test.logInfo(mLogger); + return; + } + if (!mLicenseHolder.sendHash(hash, test, worker)) return; + byte[] encryptedFrame = encryptor.encryptFrame(clearFrame, test); + if (encryptedFrame == null) { + mLogger.setError("Encryption failed."); + test.logInfo(mLogger); + return; + } + boolean lastFrame = (i + 1 == testCount); + + if (!mCodecHandler.sendData(test, clearFrame, encryptedFrame, lastFrame)) { + test.logInfo(mLogger); + return; + } + mLogger.setStatus(i, testCount); + if (mLicenseHolder.foundError(mTestCaseFactory, worker)) { + worker.handleError(mLogger.getErrorString()); + mLogger.logInfo("got hash error, frame " + test.getFrameNumber(), true); + return; + } + } + mLogger.setStatus("Finishing test."); + mCodecHandler.finishFrames(); + + if (mLicenseHolder.foundError(mTestCaseFactory, worker)) { + worker.handleError(mLogger.getErrorString()); + return; + } + mLogger.setStatus("Test finished."); + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/Worker.java b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/Worker.java new file mode 100644 index 00000000..0251173f --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/java/com/google/widevine/fulldecryptpathtesting/Worker.java @@ -0,0 +1,131 @@ +// Copyright 2019 Google LLC. All Rights Reserved. This file and proprietary +// source code may only be used and distributed under the Widevine Master +// License Agreement. + +package com.google.widevine.fulldecryptpathtesting; + +// This is the main worker thread that runs each test case. +class Worker extends Thread implements IWorker { + private static final String TAG = "FDPT_Worker"; + + private boolean mFinished = false; // True when the main application is done. + private boolean mPaused = true; // True if the main activity is not visible. + private boolean mRunTest = false; // True if a test is running or should start. + + private TestRunner mTestRunner; + private Logger mLogger; + + Worker(Logger logger) { + super("Worker Thread"); + this.mLogger = logger; + } + + @Override + public void run() { + try { + while (!mFinished) { + if (shouldStartTest()) { + mTestRunner.doTest(this); + finishTest(); + } + } + } catch (InterruptedException ex) { + mLogger.logInfo("worker interrupted: " + ex.getMessage(), true); + } + } + + // This is called by the main activity when the application is not visible. + synchronized void onPause() { + mPaused = true; + notifyAll(); + } + + // This is called by the main activity when the application becomes visible. + synchronized void onResume() { + mPaused = false; + notifyAll(); + } + + // This is called by the main activity when the application closes. + synchronized void onStop() { + mFinished = true; + notifyAll(); + } + + // This is called by the UI thread to start running a test. + synchronized void startTest(TestRunner testRunner) { + if (mRunTest) { + mLogger.logInfo("Test already started.", true); + } else { + mRunTest = true; + mTestRunner = testRunner; + } + notifyAll(); + } + + // This is called by the UI thread to cancel a running test. + // Run by worker thread. + synchronized void cancelTest() { + if (!mRunTest) { + mLogger.logInfo("Test not running.", true); + } else { + mRunTest = false; + } + notifyAll(); + } + + // This is called by the worker to see if a test should be started. + // It blocks until a test is started or the application ends. + // Run by worker thread. + private synchronized boolean shouldStartTest() { + if (!mFinished && !mRunTest) { + try { + wait(); + } catch (InterruptedException e) { + mLogger.logInfo("Worker Thread interrupted.", true); + } + } + return !mFinished && mRunTest; + } + + // Check to see if the app is finished or the test has been stopped. + // Run by worker thread. + @Override + public synchronized boolean finishEarly() { + if (mFinished || !mRunTest) { + mLogger.setStatus("Test aborted."); + return true; + } + return false; + } + + @Override + public void handleException(String message, Exception e) { + mLogger.logException(message, e); + } + + @Override + public void handleError(String message) {} + + // This blocks until mPaused is false, or the test is stopped. + // Run by worker thread. + @Override + public synchronized void maybePause() { + // Also finish pausing when the app is done or the test has been stopped. + while (mPaused && !mFinished && mRunTest) { + try { + wait(); + } catch (InterruptedException e) { + mLogger.logInfo("Worker Thread interrupted.", true); + } + } + } + + // Finish the test and clean up. + synchronized void finishTest() { + if (mTestRunner != null) { + mTestRunner.cleanUp(); + } + mRunTest = false; + } +} diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/res/drawable-v24/ic_launcher_foreground.xml b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..c7bd21db --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/res/drawable/ic_launcher_background.xml b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..d5fccc53 --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/res/layout/activity_main.xml b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..c2b023db --- /dev/null +++ b/libwvdrmengine/test/java/FullDecryptPathTesting/mobile/src/main/res/layout/activity_main.xml @@ -0,0 +1,178 @@ + + + + + + + + +