Import Cobalt 19.lts.3.194831
diff --git a/src/cobalt/build/build.id b/src/cobalt/build/build.id
index 7c34122..3b17a37 100644
--- a/src/cobalt/build/build.id
+++ b/src/cobalt/build/build.id
@@ -1 +1 @@
-194115
\ No newline at end of file
+194831
\ No newline at end of file
diff --git a/src/cobalt/layout_tests/web_platform_tests.cc b/src/cobalt/layout_tests/web_platform_tests.cc
index 3227dc1..8317211 100644
--- a/src/cobalt/layout_tests/web_platform_tests.cc
+++ b/src/cobalt/layout_tests/web_platform_tests.cc
@@ -269,13 +269,7 @@
   if (test_server.empty()) {
     FilePath url_path = GetTestInputRootDirectory()
                         .Append(FILE_PATH_LITERAL("web-platform-tests"));
-#if defined(COBALT_LINUX) || defined(COBALT_WIN)
-    // Get corp configuration.
-    url_path = url_path.Append(FILE_PATH_LITERAL("corp.url"));
-#else
-    // Get lab configuration.
     url_path = url_path.Append(FILE_PATH_LITERAL("lab.url"));
-#endif
     file_util::ReadFileToString(url_path, &test_server);
     TrimWhitespaceASCII(test_server, TRIM_ALL, &test_server);
     ASSERT_FALSE(test_server.empty());
diff --git a/src/starboard/android/__init__.py b/src/starboard/android/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/starboard/android/__init__.py
diff --git a/src/starboard/android/apk/apk.gyp b/src/starboard/android/apk/apk.gyp
new file mode 100644
index 0000000..26e8f54
--- /dev/null
+++ b/src/starboard/android/apk/apk.gyp
@@ -0,0 +1,32 @@
+# Copyright 2018 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+{
+  'targets': [
+    {
+      # This target generates a stamp file that the platform deploy action can
+      # depend on to re-run the Gradle build only when any file that's checked
+      # into git within the 'apk' directory changes.
+      'target_name': 'apk_sources',
+      'type': 'none',
+      'actions': [
+        {
+          'action_name': 'apk_sources',
+          'inputs': [ '<!@(git -C <(DEPTH)/starboard/android/apk ls-files)' ],
+          'outputs': [ '<(PRODUCT_DIR)/gradle/apk_sources.stamp' ],
+          'action': [ 'touch', '<@(_outputs)' ],
+        }
+      ]
+    }
+  ]
+}
diff --git a/src/starboard/android/apk/app/CMakeLists.txt b/src/starboard/android/apk/app/CMakeLists.txt
new file mode 100644
index 0000000..3e0fd3c
--- /dev/null
+++ b/src/starboard/android/apk/app/CMakeLists.txt
@@ -0,0 +1,94 @@
+# Copyright 2016 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Since libcoat.so is an "IMPORTED" library in CMake, in order to debug in
+# Android Studio you have to manually set the debugger type so that LLDB will
+# be started:
+# Run -> Edit Configurations -> "app" -> Debugger -> Debug Type = Dual
+
+cmake_minimum_required(VERSION 3.4.1)
+
+# Map Android ABI to Cobalt architecture
+if(ANDROID_ABI STREQUAL x86)
+  set(COBALT_ARCH x86)
+elseif(ANDROID_ABI STREQUAL armeabi-v7a)
+  set(COBALT_ARCH arm)
+elseif(ANDROID_ABI STREQUAL arm64-v8a)
+  set(COBALT_ARCH arm64)
+else()
+  message(SEND_ERROR "Unsupported Android ABI: ${ANDROID_ABI}.")
+endif()
+
+# If COBALT_PRODUCT_DIR isn't set use the relative path up to the appropriate
+# 'out' directory.
+if(NOT COBALT_PRODUCT_DIR)
+  set(COBALT_PRODUCT_DIR
+      ${CMAKE_CURRENT_LIST_DIR}/../../../../out/android-${COBALT_ARCH}_${COBALT_CONFIG}
+  )
+endif()
+
+# If COBALT_CONTENT_DIR isn't set for a particular deploy target use the
+# 'content/data' directory.
+if(NOT COBALT_CONTENT_DIR)
+  set(COBALT_CONTENT_DIR
+      ${COBALT_PRODUCT_DIR}/content/data
+  )
+endif()
+
+# For platform deploy builds, use the -n parameter to skip Cobalt ninja and
+# just copy the .so that was already built and waiting in COBALT_PRODUCT_DIR.
+if(COBALT_PLATFORM_DEPLOY)
+  set(skip_ninja_arg -n)
+endif()
+
+# Run Cobalt ninja, and copy the result as our "IMPORTED" libcoat.so.
+# ("coat_lib" never gets created, so this runs every time.)
+add_custom_command(OUTPUT coat_lib
+    COMMAND ${CMAKE_CURRENT_LIST_DIR}/cobalt-ninja.sh
+            ${skip_ninja_arg} -C ${COBALT_PRODUCT_DIR} ${COBALT_TARGET}
+    COMMAND ${CMAKE_COMMAND} -E copy
+            ${COBALT_PRODUCT_DIR}/lib/lib${COBALT_TARGET}.so
+            ${CMAKE_LIBRARY_OUTPUT_DIRECTORY}/libcoat.so
+)
+
+# Make a symlink to the cobalt static content. We put it in a parent directory
+# of the library output, which corresponds to what the Gradle build sets for
+# android.sourceSets.<build-type>.assets.srcDir. This ends up overwriting the
+# symlink for each ABI built, but that's okay since the Cobalt static content
+# for a given build config is the same for all architectures.
+# ("cobalt_content" never gets created, so this runs every time.)
+add_custom_command(OUTPUT cobalt_content
+    DEPENDS coat_lib
+    COMMAND ${CMAKE_COMMAND} -E create_symlink
+            ${COBALT_CONTENT_DIR}
+            ${CMAKE_LIBRARY_OUTPUT_DIRECTORY}/../../cobalt_content
+)
+
+# We need a target (not a file) for the phony native dependency below.
+add_custom_target(external_cobalt_build DEPENDS coat_lib cobalt_content)
+
+# Declare libcoat.so as a shared library that needs to be included in the APK.
+# However, Android Studio will build it as an "IMPORTED" library.
+add_library(coat SHARED IMPORTED)
+set_target_properties(coat PROPERTIES
+    IMPORTED_LOCATION ${CMAKE_LIBRARY_OUTPUT_DIRECTORY}/libcoat.so
+)
+
+# Make a phony "native" library, so Android Studio has something to build.
+file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/phony.cpp "void __phony() {}")
+add_library(native SHARED  ${CMAKE_CURRENT_BINARY_DIR}/phony.cpp)
+
+# Add a dependency to run the external cobalt build as a side effect of
+# building the phony native library.
+add_dependencies(native external_cobalt_build)
diff --git a/src/starboard/android/apk/app/build.gradle b/src/starboard/android/apk/app/build.gradle
new file mode 100644
index 0000000..ad09589
--- /dev/null
+++ b/src/starboard/android/apk/app/build.gradle
@@ -0,0 +1,162 @@
+// Copyright 2016 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+apply plugin: 'com.android.application'
+
+final DEFAULT_COBALT_TARGET = 'cobalt'
+final String[] SUPPORTED_ABIS = [ 'x86', 'armeabi-v7a', 'arm64-v8a' ]
+
+ext {
+    // Provide defaults if these properties aren't specified on the command line.
+    buildAbi = project.hasProperty('cobaltBuildAbi') ? cobaltBuildAbi : SUPPORTED_ABIS
+    cobaltTarget = project.hasProperty('cobaltTarget') ? cobaltTarget : DEFAULT_COBALT_TARGET
+    cobaltProductDir =
+            project.hasProperty('cobaltProductDir') ? new File(cobaltProductDir).canonicalPath : ''
+    cobaltContentDir =
+            project.hasProperty('cobaltContentDir') ? new File(cobaltContentDir).canonicalPath : ''
+
+    buildIdFile = rootProject.file('build.id')
+    buildId = buildIdFile.exists() ? buildIdFile.text.trim() : '0'
+}
+
+println "TARGET: ${cobaltTarget}"
+
+android {
+    compileSdkVersion 28
+    buildToolsVersion "28.0.3"
+
+    signingConfigs {
+        // A signing config that matches what is implicitly used for the "debug" build type.
+        debugConfig {
+            keyAlias 'androiddebugkey'
+            keyPassword 'android'
+            storeFile file("${System.getProperty('user.home')}/.android/debug.keystore")
+            storePassword 'android'
+        }
+    }
+    defaultConfig {
+        applicationId "dev.cobalt.coat"
+        minSdkVersion 21
+        targetSdkVersion 28
+        versionCode 1
+        versionName "${buildId}"
+        manifestPlaceholders = [applicationName: "CoAT: ${cobaltTarget}"]
+        externalNativeBuild {
+            cmake {
+                arguments "-DCOBALT_TARGET=${cobaltTarget}"
+                arguments "-DCOBALT_PRODUCT_DIR=${cobaltProductDir}"
+                arguments "-DCOBALT_CONTENT_DIR=${cobaltContentDir}"
+                arguments "-DCOBALT_PLATFORM_DEPLOY=${project.hasProperty('cobaltDeployApk')}"
+            }
+        }
+    }
+    splits {
+        abi {
+            enable true
+            reset()
+            include buildAbi
+        }
+    }
+    buildTypes {
+        debug {
+            debuggable true
+            jniDebuggable true
+            externalNativeBuild {
+                cmake.arguments "-DCOBALT_CONFIG=debug"
+            }
+            signingConfig signingConfigs.debugConfig
+        }
+        devel {
+            debuggable true
+            jniDebuggable true
+            externalNativeBuild {
+                cmake.arguments "-DCOBALT_CONFIG=devel"
+            }
+            signingConfig signingConfigs.debugConfig
+        }
+        qa {
+            debuggable true
+            jniDebuggable true
+            externalNativeBuild {
+                cmake.arguments "-DCOBALT_CONFIG=qa"
+            }
+            signingConfig signingConfigs.debugConfig
+        }
+        release {
+            minifyEnabled true
+            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+            externalNativeBuild {
+                cmake.arguments "-DCOBALT_CONFIG=gold"
+            }
+            signingConfig signingConfigs.debugConfig
+        }
+    }
+    sourceSets {
+        // The source is split into two parts:
+        //   "main" is the code that should go into any Cobalt-based app for Android TV.
+        //   "app" is the specialization needed to make "the blue app" out of the main code.
+        // The Android Gradle plugin includes "main" by default, so we just need to add "app".
+        main {
+            manifest.srcFile "src/app/AndroidManifest.xml"
+            java.srcDir "src/app/java"
+            res.srcDir "src/app/res"
+            assets.srcDir "src/app/assets"
+        }
+        // Add the directories symlinked by the CMake "cobalt_content" custom command.
+        debug {
+            assets.srcDir "${buildDir}/intermediates/cmake/debug/cobalt_content"
+        }
+        devel {
+            assets.srcDir "${buildDir}/intermediates/cmake/devel/cobalt_content"
+        }
+        qa {
+            assets.srcDir "${buildDir}/intermediates/cmake/qa/cobalt_content"
+        }
+        release {
+            assets.srcDir "${buildDir}/intermediates/cmake/release/cobalt_content"
+        }
+    }
+    externalNativeBuild {
+        cmake {
+            path 'CMakeLists.txt'
+            if (project.hasProperty('cobaltGradleDir')) {
+                // Resolve relative to the current dir at config time by getting a canonical File.
+                buildStagingDirectory new File(cobaltGradleDir, 'externalNativeBuild').canonicalFile
+            }
+        }
+    }
+}
+
+// When running in the platform deploy action, make tasks that depend on the appropriate Android
+// assemble tasks, then the move the assembled "app" apk to the desired path for deployment.
+if (project.hasProperty('cobaltDeployApk')) {
+    android.applicationVariants.all { variant ->
+        task "assembleCobalt_${variant.name}"(dependsOn: "assemble${variant.name.capitalize()}") {
+            def assembledApk = variant.outputs[0].outputFile
+            def deployApk = new File(cobaltDeployApk).canonicalFile
+            doLast {
+                assembledApk.renameTo(deployApk)
+            }
+        }
+    }
+}
+
+dependencies {
+    implementation fileTree(include: ['*.jar'], dir: 'libs')
+    implementation 'com.android.support:support-annotations:28.0.0'
+    implementation 'com.android.support:leanback-v17:28.0.0'
+    implementation 'com.android.support:support-v4:28.0.0'
+    implementation 'com.google.android.gms:play-services-auth:16.0.1'
+    implementation 'com.google.protobuf:protobuf-lite:3.0.1'
+}
diff --git a/src/starboard/android/apk/app/cobalt-ninja.sh b/src/starboard/android/apk/app/cobalt-ninja.sh
new file mode 100755
index 0000000..6dc1375
--- /dev/null
+++ b/src/starboard/android/apk/app/cobalt-ninja.sh
@@ -0,0 +1,47 @@
+#! /bin/bash
+# Copyright 2016 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This wrapper allows us to run ninja without any assumptions about the
+# environment having been setup correctly to build Cobalt, since that won't
+# happen when started from Android Studio. See:
+# https://cobalt.googlesource.com/cobalt/+/master/src/#Building-and-Running-the-Code
+
+# Allow for a developer-specific environment setup from .cobaltrc
+# e.g., it may set DEPOT_TOOLS and/or setup some distributed build tools.
+local_rc=$(dirname $0)/.cobaltrc
+global_rc=${HOME}/.cobaltrc
+if [ -r ${local_rc} ]; then
+  source ${local_rc}
+elif [ -r ${global_rc} ]; then
+  source ${global_rc}
+fi
+
+# DEPOT_TOOLS may be set in .cobaltrc, otherwise assume it's in $HOME.
+[ -x ${DEPOT_TOOLS}/ninja ] || DEPOT_TOOLS=${HOME}/depot_tools
+
+# Use Cobalt's clang if it's not anywhere earlier in the PATH.
+SRC_DIR=$(cd $(dirname $0)/../../../..; pwd)
+PATH=$PATH:${SRC_DIR}/third_party/llvm-build/Release+Asserts/bin
+
+# Do nothing if -n is the first argument.
+if [ "$1" == "-n" ]; then
+  shift
+  echo "Skipping: ninja $@"
+  exit
+fi
+
+# When running in CMake, depot_tools isn't in the path, so we have to be
+# explicit about which ninja to run. Fail if we didn't find depot_tools.
+exec ${DEPOT_TOOLS}/ninja "$@"
diff --git a/src/starboard/android/apk/app/proguard-rules.pro b/src/starboard/android/apk/app/proguard-rules.pro
new file mode 100644
index 0000000..a4b8a54
--- /dev/null
+++ b/src/starboard/android/apk/app/proguard-rules.pro
@@ -0,0 +1,35 @@
+# Copyright 2016 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Proguard flags needed for the Android Starboard Java implementation to work
+# properly with its JNI native library.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# JNI is an entry point that's hard to keep track of, so the @UsedByNative
+# annotation marks fields and methods used by native code.
+
+# Annotations are implemented as attributes, so we have to explicitly keep them.
+-keepattributes *Annotation*
+
+# Keep classes, methods, and fields that are accessed with JNI.
+-keep @interface dev.cobalt.util.UsedByNative
+-keep @dev.cobalt.util.UsedByNative class *
+-keepclasseswithmembers class * {
+  @dev.cobalt.util.UsedByNative <methods>;
+}
+-keepclasseswithmembers class * {
+  @dev.cobalt.util.UsedByNative <fields>;
+}
diff --git a/src/starboard/android/apk/app/src/app/AndroidManifest.xml b/src/starboard/android/apk/app/src/app/AndroidManifest.xml
new file mode 100644
index 0000000..7337495
--- /dev/null
+++ b/src/starboard/android/apk/app/src/app/AndroidManifest.xml
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2016 The Cobalt Authors. All Rights Reserved.
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+  package="dev.cobalt.coat">
+
+  <uses-feature android:name="android.hardware.microphone" android:required="false"/>
+  <uses-feature android:name="android.software.leanback" android:required="false"/>
+  <uses-feature android:name="android.hardware.touchscreen" android:required="false"/>
+  <uses-feature android:glEsVersion="0x00020000" android:required="true"/>
+
+  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
+  <uses-permission android:name="android.permission.GET_ACCOUNTS"/>
+  <uses-permission android:name="android.permission.USE_CREDENTIALS" android:maxSdkVersion="22"/>
+  <uses-permission android:name="android.permission.INTERNET"/>
+  <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
+  <uses-permission android:name="android.permission.RECORD_AUDIO"/>
+
+  <application
+    android:name="dev.cobalt.app.CobaltApplication"
+    android:icon="@mipmap/ic_app"
+    android:banner="@drawable/app_banner"
+    android:label="${applicationName}">
+
+    <activity android:name="dev.cobalt.app.MainActivity"
+      android:launchMode="singleTask"
+      android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|uiMode"
+      android:screenOrientation="sensorLandscape"
+      android:theme="@style/CobaltTheme">
+      <meta-data android:name="cobalt.APP_URL" android:value="https://www.youtube.com/tv"/>
+      <meta-data android:name="cobalt.SPLASH_URL" android:value="h5vcc-embedded://cobalt_splash_screen.html"/>
+      <meta-data android:name="android.app.lib_name" android:value="coat"/>
+      <intent-filter>
+        <action android:name="android.intent.action.MAIN"/>
+        <category android:name="android.intent.category.LAUNCHER"/>
+        <category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
+        <category android:name="android.intent.category.DEFAULT"/>
+      </intent-filter>
+      <intent-filter>
+        <action android:name="android.intent.action.VIEW"/>
+        <action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH"/>
+        <category android:name="android.intent.category.DEFAULT"/>
+        <category android:name="android.intent.category.BROWSABLE"/>
+        <data android:scheme="http"/>
+        <data android:scheme="https"/>
+        <data android:host="youtube.com"/>
+        <data android:host="www.youtube.com"/>
+        <data android:host="m.youtube.com"/>
+        <data android:host="youtu.be"/>
+        <data android:pathPattern=".*"/>
+      </intent-filter>
+    </activity>
+
+  </application>
+
+</manifest>
diff --git a/src/starboard/android/apk/app/src/app/assets/web/cobalt_blue_splash_screen.css b/src/starboard/android/apk/app/src/app/assets/web/cobalt_blue_splash_screen.css
new file mode 100644
index 0000000..e3ca851
--- /dev/null
+++ b/src/starboard/android/apk/app/src/app/assets/web/cobalt_blue_splash_screen.css
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2017 The Cobalt Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+*/
+
+body {
+  overflow: hidden;
+  font-size: 1.4815vh;  /* Corresponds to 16px at 1080p. */
+}
+
+#splash {
+  background-color: #16396b;
+  background-image: url("file:///cobalt_logo_1024.png");
+  background-position: center center;
+  background-repeat: no-repeat;
+  background-size: auto 33%;
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+
+#loading {
+  position: absolute;
+  top: 52em;
+  width: 100%;
+}
+
+#spinner {
+  /* The spinner starts with display set to none, and JavaScript will set this
+     to 'block' after some time has passed, if the splash screen is still
+     visible. */
+  display: none;
+
+  height: 5.33em;
+  margin: 0 auto;
+  position: relative;
+  width: 5.33em;
+}
+
+.dot {
+  background-color: #cbcbcb;
+  border-radius: 50%;
+  height: 1.17em;
+  position: absolute;
+  width: 1.17em;
+}
+
+@keyframes fade1 {
+  0%,100% {opacity: 0}
+  50% {opacity: 1}
+}
+
+@keyframes fade2 {
+  0%,100% {opacity: .25}
+  37.5% {opacity: 1}
+  87.5% {opacity: 0}
+}
+
+@keyframes fade3 {
+  0%,100% {opacity: .5}
+  25% {opacity: 1}
+  75% {opacity: 0}
+}
+
+@keyframes fade4 {
+  0%,100% {opacity: .75}
+  12.5% {opacity: 1}
+  62.5% {opacity: 0}
+}
+
+@keyframes fade5 {
+  0%,100% {opacity: 1}
+  50% {opacity: 0}
+}
+
+@keyframes fade6 {
+  0%,100% {opacity: .75}
+  37.5% {opacity: 0}
+  87.5% {opacity: 1}
+}
+
+@keyframes fade7 {
+  0%,100% {opacity: .5}
+  25% {opacity: 0}
+  75% {opacity: 1}
+}
+
+@keyframes fade8 {
+  0%,100% {opacity: .25}
+  12.5% {opacity: 0}
+  62.5% {opacity: 1}
+}
+
+#dot1 {
+  animation: fade8 .72s infinite ease;
+  left: 0;
+  top: 2.09em;
+}
+
+#dot2 {
+  animation: fade7 .72s infinite ease;
+  left: .61em;
+  top: .61em;
+}
+
+#dot3 {
+  animation: fade6 .72s infinite ease;
+  left: 2.09em;
+  top: 0;
+}
+
+#dot4 {
+  animation: fade5 .72s infinite ease;
+  right: .61em;
+  top: .61em;
+}
+
+#dot5 {
+  animation: fade4 .72s infinite ease;
+  right: 0;
+  top: 2.09em;
+}
+
+#dot6 {
+  animation: fade3 .72s infinite ease;
+  bottom: .61em;
+  right: .61em;
+}
+
+#dot7 {
+  animation: fade2 .72s infinite ease;
+  bottom: 0;
+  left: 2.09em;
+}
+
+#dot8 {
+  animation: fade1 .72s infinite ease;
+  bottom: .61em;
+  left: .61em;
+}
+
+.hidden {
+  height: 0;
+  visibility: hidden;
+}
diff --git a/src/starboard/android/apk/app/src/app/assets/web/cobalt_blue_splash_screen.html b/src/starboard/android/apk/app/src/app/assets/web/cobalt_blue_splash_screen.html
new file mode 100644
index 0000000..3a453a6
--- /dev/null
+++ b/src/starboard/android/apk/app/src/app/assets/web/cobalt_blue_splash_screen.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<!--
+  Copyright 2017 The Cobalt Authors. All Rights Reserved.
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<html>
+
+<head>
+  <meta http-equiv="Content-Security-Policy" content="default-src 'none';
+      script-src h5vcc-embedded://*/splash_screen.js;
+      style-src file://*/cobalt_blue_splash_screen.css;
+      img-src file://*/cobalt_logo_1024.png;">
+<link rel="stylesheet" type="text/css"
+    href="file:///cobalt_blue_splash_screen.css">
+</head>
+
+<body>
+  <div id="splash">
+    <img src="file:///cobalt_logo_1024.png" class="hidden">
+  </div>
+  <div id="loading">
+    <div id="spinner">
+      <div class="dot" id="dot1"></div>
+      <div class="dot" id="dot2"></div>
+      <div class="dot" id="dot3"></div>
+      <div class="dot" id="dot4"></div>
+      <div class="dot" id="dot5"></div>
+      <div class="dot" id="dot6"></div>
+      <div class="dot" id="dot7"></div>
+      <div class="dot" id="dot8"></div>
+    </div>
+  </div>
+  <script type="text/javascript" src="h5vcc-embedded://splash_screen.js">
+  </script>
+</body>
+
+</html>
diff --git a/src/starboard/android/apk/app/src/app/assets/web/cobalt_logo_1024.png b/src/starboard/android/apk/app/src/app/assets/web/cobalt_logo_1024.png
new file mode 100644
index 0000000..443a571
--- /dev/null
+++ b/src/starboard/android/apk/app/src/app/assets/web/cobalt_logo_1024.png
Binary files differ
diff --git a/src/starboard/android/apk/app/src/app/assets/web/link_android_splash_screen.html b/src/starboard/android/apk/app/src/app/assets/web/link_android_splash_screen.html
new file mode 100644
index 0000000..4aa1686
--- /dev/null
+++ b/src/starboard/android/apk/app/src/app/assets/web/link_android_splash_screen.html
@@ -0,0 +1,691 @@
+<!DOCTYPE html>
+<!--
+  Copyright 2017 The Cobalt Authors. All Rights Reserved.
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<html>
+
+<head>
+  <meta http-equiv="Content-Security-Policy" content="
+    default-src 'unsafe-inline';
+    script-src file://*/cobalt_blue_splash_screen.html;
+    style-src 'unsafe-inline';"
+</head>
+
+<body style="background-color: #1f52a5;">
+  <link rel="splashscreen" href="file:///cobalt_blue_splash_screen.html">
+<h1>Heading</h1>
+
+<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus
+interdum maximus finibus. Ut fermentum malesuada commodo. Sed
+faucibus, sapien a mattis lobortis, magna ante efficitur mauris, quis
+sodales nibh diam nec quam. Vestibulum magna libero, tincidunt non
+erat sed, molestie pulvinar ex. Maecenas semper blandit elit, id
+suscipit nulla venenatis pretium. Integer accumsan porta felis, vitae
+placerat urna accumsan vel. Aliquam eu aliquet mi. Aenean tincidunt
+eros lectus, sit amet efficitur orci ultrices at. Morbi lobortis ex
+quis luctus rutrum. In nulla velit, elementum vitae turpis vitae,
+finibus varius massa. Morbi id libero faucibus, tempus eros et,
+ullamcorper ipsum. Sed eleifend finibus bibendum. Nullam ut nunc nec
+lacus posuere dignissim. Nunc sollicitudin vitae augue id
+vulputate. Ut ac nibh gravida, volutpat est ac, facilisis neque.</p>
+
+<p>Nam dictum leo massa, non posuere dui bibendum id. Morbi sagittis est
+non est laoreet, a sollicitudin felis aliquet. Ut cursus vel leo a
+efficitur. Proin ut pellentesque sapien, vel maximus dui. Suspendisse
+eu felis eget leo elementum efficitur. Class aptent taciti sociosqu ad
+litora torquent per conubia nostra, per inceptos himenaeos. Fusce
+lobortis velit in elit pellentesque, ut auctor ipsum dignissim. Sed
+aliquet eleifend convallis. Duis mollis, dolor sed rutrum mollis,
+augue eros dignissim erat, eu dapibus augue turpis ac sapien. Morbi at
+volutpat odio, at molestie risus. Nulla quis nulla et magna vestibulum
+euismod. Praesent suscipit quam elit, non luctus turpis rutrum
+faucibus.</p>
+
+<p>Morbi feugiat lacus rhoncus, dignissim velit nec, dignissim
+lorem. Aliquam erat volutpat. Mauris semper dictum tempus. Nulla ex
+ligula, malesuada in ornare sed, euismod vitae massa. Etiam quis erat
+quis nisl facilisis suscipit. Mauris placerat ante et auctor
+fermentum. Donec tincidunt justo sem, ullamcorper vulputate nisl
+commodo a. Vestibulum quis ex non elit porttitor semper eget quis
+tortor. Suspendisse mattis neque non elementum scelerisque. Nulla
+facilisi. Nulla non felis et justo feugiat elementum. Aenean sodales
+turpis at erat eleifend lacinia. Proin eleifend volutpat purus id
+mollis. Proin vel tellus faucibus, sagittis libero at, lobortis
+odio. Praesent quam mauris, auctor vel velit eu, convallis molestie
+nisi. Pellentesque in nunc at orci ultrices vehicula.</p>
+
+<p>Praesent nibh lectus, efficitur sed risus in, rutrum tristique
+arcu. Curabitur non efficitur elit. Phasellus eget odio iaculis,
+molestie dui eget, venenatis erat. Nulla luctus facilisis lectus, nec
+dapibus tortor rhoncus vel. Donec nec arcu elit. Nullam ut faucibus
+purus, sed ultricies diam. Pellentesque at finibus ipsum. Vestibulum
+egestas dignissim nisl, ac rhoncus risus finibus sit amet. Donec non
+feugiat ante. Donec vehicula dui a lorem imperdiet, a tempus diam
+pulvinar. Nullam congue efficitur justo, non posuere ligula sodales
+in. Ut a urna ornare, ultrices velit in, pellentesque
+lorem. Vestibulum ante ipsum primis in faucibus orci luctus et
+ultrices posuere cubilia Curae;</p>
+
+<p>Orci varius natoque penatibus et magnis dis parturient montes,
+nascetur ridiculus mus. Morbi maximus quis magna et aliquet. Nam
+bibendum fermentum tempus. Praesent iaculis tortor metus, at
+vestibulum ipsum hendrerit mattis. Proin fringilla nisl sit amet
+tincidunt blandit. Interdum et malesuada fames ac ante ipsum primis in
+faucibus. Phasellus vel lectus leo. Curabitur fringilla, arcu non
+posuere viverra, urna metus blandit augue, convallis mattis tortor dui
+vel arcu. In sit amet metus vitae ex rhoncus hendrerit.</p>
+
+<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus
+interdum maximus finibus. Ut fermentum malesuada commodo. Sed
+faucibus, sapien a mattis lobortis, magna ante efficitur mauris, quis
+sodales nibh diam nec quam. Vestibulum magna libero, tincidunt non
+erat sed, molestie pulvinar ex. Maecenas semper blandit elit, id
+suscipit nulla venenatis pretium. Integer accumsan porta felis, vitae
+placerat urna accumsan vel. Aliquam eu aliquet mi. Aenean tincidunt
+eros lectus, sit amet efficitur orci ultrices at. Morbi lobortis ex
+quis luctus rutrum. In nulla velit, elementum vitae turpis vitae,
+finibus varius massa. Morbi id libero faucibus, tempus eros et,
+ullamcorper ipsum. Sed eleifend finibus bibendum. Nullam ut nunc nec
+lacus posuere dignissim. Nunc sollicitudin vitae augue id
+vulputate. Ut ac nibh gravida, volutpat est ac, facilisis neque.</p>
+
+<p>Nam dictum leo massa, non posuere dui bibendum id. Morbi sagittis est
+non est laoreet, a sollicitudin felis aliquet. Ut cursus vel leo a
+efficitur. Proin ut pellentesque sapien, vel maximus dui. Suspendisse
+eu felis eget leo elementum efficitur. Class aptent taciti sociosqu ad
+litora torquent per conubia nostra, per inceptos himenaeos. Fusce
+lobortis velit in elit pellentesque, ut auctor ipsum dignissim. Sed
+aliquet eleifend convallis. Duis mollis, dolor sed rutrum mollis,
+augue eros dignissim erat, eu dapibus augue turpis ac sapien. Morbi at
+volutpat odio, at molestie risus. Nulla quis nulla et magna vestibulum
+euismod. Praesent suscipit quam elit, non luctus turpis rutrum
+faucibus.</p>
+
+<p>Morbi feugiat lacus rhoncus, dignissim velit nec, dignissim
+lorem. Aliquam erat volutpat. Mauris semper dictum tempus. Nulla ex
+ligula, malesuada in ornare sed, euismod vitae massa. Etiam quis erat
+quis nisl facilisis suscipit. Mauris placerat ante et auctor
+fermentum. Donec tincidunt justo sem, ullamcorper vulputate nisl
+commodo a. Vestibulum quis ex non elit porttitor semper eget quis
+tortor. Suspendisse mattis neque non elementum scelerisque. Nulla
+facilisi. Nulla non felis et justo feugiat elementum. Aenean sodales
+turpis at erat eleifend lacinia. Proin eleifend volutpat purus id
+mollis. Proin vel tellus faucibus, sagittis libero at, lobortis
+odio. Praesent quam mauris, auctor vel velit eu, convallis molestie
+nisi. Pellentesque in nunc at orci ultrices vehicula.</p>
+
+<p>Praesent nibh lectus, efficitur sed risus in, rutrum tristique
+arcu. Curabitur non efficitur elit. Phasellus eget odio iaculis,
+molestie dui eget, venenatis erat. Nulla luctus facilisis lectus, nec
+dapibus tortor rhoncus vel. Donec nec arcu elit. Nullam ut faucibus
+purus, sed ultricies diam. Pellentesque at finibus ipsum. Vestibulum
+egestas dignissim nisl, ac rhoncus risus finibus sit amet. Donec non
+feugiat ante. Donec vehicula dui a lorem imperdiet, a tempus diam
+pulvinar. Nullam congue efficitur justo, non posuere ligula sodales
+in. Ut a urna ornare, ultrices velit in, pellentesque
+lorem. Vestibulum ante ipsum primis in faucibus orci luctus et
+ultrices posuere cubilia Curae;</p>
+
+<p>Orci varius natoque penatibus et magnis dis parturient montes,
+nascetur ridiculus mus. Morbi maximus quis magna et aliquet. Nam
+bibendum fermentum tempus. Praesent iaculis tortor metus, at
+vestibulum ipsum hendrerit mattis. Proin fringilla nisl sit amet
+tincidunt blandit. Interdum et malesuada fames ac ante ipsum primis in
+faucibus. Phasellus vel lectus leo. Curabitur fringilla, arcu non
+posuere viverra, urna metus blandit augue, convallis mattis tortor dui
+vel arcu. In sit amet metus vitae ex rhoncus hendrerit.</p>
+
+<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus
+interdum maximus finibus. Ut fermentum malesuada commodo. Sed
+faucibus, sapien a mattis lobortis, magna ante efficitur mauris, quis
+sodales nibh diam nec quam. Vestibulum magna libero, tincidunt non
+erat sed, molestie pulvinar ex. Maecenas semper blandit elit, id
+suscipit nulla venenatis pretium. Integer accumsan porta felis, vitae
+placerat urna accumsan vel. Aliquam eu aliquet mi. Aenean tincidunt
+eros lectus, sit amet efficitur orci ultrices at. Morbi lobortis ex
+quis luctus rutrum. In nulla velit, elementum vitae turpis vitae,
+finibus varius massa. Morbi id libero faucibus, tempus eros et,
+ullamcorper ipsum. Sed eleifend finibus bibendum. Nullam ut nunc nec
+lacus posuere dignissim. Nunc sollicitudin vitae augue id
+vulputate. Ut ac nibh gravida, volutpat est ac, facilisis neque.</p>
+
+<p>Nam dictum leo massa, non posuere dui bibendum id. Morbi sagittis est
+non est laoreet, a sollicitudin felis aliquet. Ut cursus vel leo a
+efficitur. Proin ut pellentesque sapien, vel maximus dui. Suspendisse
+eu felis eget leo elementum efficitur. Class aptent taciti sociosqu ad
+litora torquent per conubia nostra, per inceptos himenaeos. Fusce
+lobortis velit in elit pellentesque, ut auctor ipsum dignissim. Sed
+aliquet eleifend convallis. Duis mollis, dolor sed rutrum mollis,
+augue eros dignissim erat, eu dapibus augue turpis ac sapien. Morbi at
+volutpat odio, at molestie risus. Nulla quis nulla et magna vestibulum
+euismod. Praesent suscipit quam elit, non luctus turpis rutrum
+faucibus.</p>
+
+<p>Morbi feugiat lacus rhoncus, dignissim velit nec, dignissim
+lorem. Aliquam erat volutpat. Mauris semper dictum tempus. Nulla ex
+ligula, malesuada in ornare sed, euismod vitae massa. Etiam quis erat
+quis nisl facilisis suscipit. Mauris placerat ante et auctor
+fermentum. Donec tincidunt justo sem, ullamcorper vulputate nisl
+commodo a. Vestibulum quis ex non elit porttitor semper eget quis
+tortor. Suspendisse mattis neque non elementum scelerisque. Nulla
+facilisi. Nulla non felis et justo feugiat elementum. Aenean sodales
+turpis at erat eleifend lacinia. Proin eleifend volutpat purus id
+mollis. Proin vel tellus faucibus, sagittis libero at, lobortis
+odio. Praesent quam mauris, auctor vel velit eu, convallis molestie
+nisi. Pellentesque in nunc at orci ultrices vehicula.</p>
+
+<p>Praesent nibh lectus, efficitur sed risus in, rutrum tristique
+arcu. Curabitur non efficitur elit. Phasellus eget odio iaculis,
+molestie dui eget, venenatis erat. Nulla luctus facilisis lectus, nec
+dapibus tortor rhoncus vel. Donec nec arcu elit. Nullam ut faucibus
+purus, sed ultricies diam. Pellentesque at finibus ipsum. Vestibulum
+egestas dignissim nisl, ac rhoncus risus finibus sit amet. Donec non
+feugiat ante. Donec vehicula dui a lorem imperdiet, a tempus diam
+pulvinar. Nullam congue efficitur justo, non posuere ligula sodales
+in. Ut a urna ornare, ultrices velit in, pellentesque
+lorem. Vestibulum ante ipsum primis in faucibus orci luctus et
+ultrices posuere cubilia Curae;</p>
+
+<p>Orci varius natoque penatibus et magnis dis parturient montes,
+nascetur ridiculus mus. Morbi maximus quis magna et aliquet. Nam
+bibendum fermentum tempus. Praesent iaculis tortor metus, at
+vestibulum ipsum hendrerit mattis. Proin fringilla nisl sit amet
+tincidunt blandit. Interdum et malesuada fames ac ante ipsum primis in
+faucibus. Phasellus vel lectus leo. Curabitur fringilla, arcu non
+posuere viverra, urna metus blandit augue, convallis mattis tortor dui
+vel arcu. In sit amet metus vitae ex rhoncus hendrerit.</p>
+
+<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus
+interdum maximus finibus. Ut fermentum malesuada commodo. Sed
+faucibus, sapien a mattis lobortis, magna ante efficitur mauris, quis
+sodales nibh diam nec quam. Vestibulum magna libero, tincidunt non
+erat sed, molestie pulvinar ex. Maecenas semper blandit elit, id
+suscipit nulla venenatis pretium. Integer accumsan porta felis, vitae
+placerat urna accumsan vel. Aliquam eu aliquet mi. Aenean tincidunt
+eros lectus, sit amet efficitur orci ultrices at. Morbi lobortis ex
+quis luctus rutrum. In nulla velit, elementum vitae turpis vitae,
+finibus varius massa. Morbi id libero faucibus, tempus eros et,
+ullamcorper ipsum. Sed eleifend finibus bibendum. Nullam ut nunc nec
+lacus posuere dignissim. Nunc sollicitudin vitae augue id
+vulputate. Ut ac nibh gravida, volutpat est ac, facilisis neque.</p>
+
+<p>Nam dictum leo massa, non posuere dui bibendum id. Morbi sagittis est
+non est laoreet, a sollicitudin felis aliquet. Ut cursus vel leo a
+efficitur. Proin ut pellentesque sapien, vel maximus dui. Suspendisse
+eu felis eget leo elementum efficitur. Class aptent taciti sociosqu ad
+litora torquent per conubia nostra, per inceptos himenaeos. Fusce
+lobortis velit in elit pellentesque, ut auctor ipsum dignissim. Sed
+aliquet eleifend convallis. Duis mollis, dolor sed rutrum mollis,
+augue eros dignissim erat, eu dapibus augue turpis ac sapien. Morbi at
+volutpat odio, at molestie risus. Nulla quis nulla et magna vestibulum
+euismod. Praesent suscipit quam elit, non luctus turpis rutrum
+faucibus.</p>
+
+<p>Morbi feugiat lacus rhoncus, dignissim velit nec, dignissim
+lorem. Aliquam erat volutpat. Mauris semper dictum tempus. Nulla ex
+ligula, malesuada in ornare sed, euismod vitae massa. Etiam quis erat
+quis nisl facilisis suscipit. Mauris placerat ante et auctor
+fermentum. Donec tincidunt justo sem, ullamcorper vulputate nisl
+commodo a. Vestibulum quis ex non elit porttitor semper eget quis
+tortor. Suspendisse mattis neque non elementum scelerisque. Nulla
+facilisi. Nulla non felis et justo feugiat elementum. Aenean sodales
+turpis at erat eleifend lacinia. Proin eleifend volutpat purus id
+mollis. Proin vel tellus faucibus, sagittis libero at, lobortis
+odio. Praesent quam mauris, auctor vel velit eu, convallis molestie
+nisi. Pellentesque in nunc at orci ultrices vehicula.</p>
+
+<p>Praesent nibh lectus, efficitur sed risus in, rutrum tristique
+arcu. Curabitur non efficitur elit. Phasellus eget odio iaculis,
+molestie dui eget, venenatis erat. Nulla luctus facilisis lectus, nec
+dapibus tortor rhoncus vel. Donec nec arcu elit. Nullam ut faucibus
+purus, sed ultricies diam. Pellentesque at finibus ipsum. Vestibulum
+egestas dignissim nisl, ac rhoncus risus finibus sit amet. Donec non
+feugiat ante. Donec vehicula dui a lorem imperdiet, a tempus diam
+pulvinar. Nullam congue efficitur justo, non posuere ligula sodales
+in. Ut a urna ornare, ultrices velit in, pellentesque
+lorem. Vestibulum ante ipsum primis in faucibus orci luctus et
+ultrices posuere cubilia Curae;</p>
+
+<p>Orci varius natoque penatibus et magnis dis parturient montes,
+nascetur ridiculus mus. Morbi maximus quis magna et aliquet. Nam
+bibendum fermentum tempus. Praesent iaculis tortor metus, at
+vestibulum ipsum hendrerit mattis. Proin fringilla nisl sit amet
+tincidunt blandit. Interdum et malesuada fames ac ante ipsum primis in
+faucibus. Phasellus vel lectus leo. Curabitur fringilla, arcu non
+posuere viverra, urna metus blandit augue, convallis mattis tortor dui
+vel arcu. In sit amet metus vitae ex rhoncus hendrerit.</p>
+
+<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus
+interdum maximus finibus. Ut fermentum malesuada commodo. Sed
+faucibus, sapien a mattis lobortis, magna ante efficitur mauris, quis
+sodales nibh diam nec quam. Vestibulum magna libero, tincidunt non
+erat sed, molestie pulvinar ex. Maecenas semper blandit elit, id
+suscipit nulla venenatis pretium. Integer accumsan porta felis, vitae
+placerat urna accumsan vel. Aliquam eu aliquet mi. Aenean tincidunt
+eros lectus, sit amet efficitur orci ultrices at. Morbi lobortis ex
+quis luctus rutrum. In nulla velit, elementum vitae turpis vitae,
+finibus varius massa. Morbi id libero faucibus, tempus eros et,
+ullamcorper ipsum. Sed eleifend finibus bibendum. Nullam ut nunc nec
+lacus posuere dignissim. Nunc sollicitudin vitae augue id
+vulputate. Ut ac nibh gravida, volutpat est ac, facilisis neque.</p>
+
+<p>Nam dictum leo massa, non posuere dui bibendum id. Morbi sagittis est
+non est laoreet, a sollicitudin felis aliquet. Ut cursus vel leo a
+efficitur. Proin ut pellentesque sapien, vel maximus dui. Suspendisse
+eu felis eget leo elementum efficitur. Class aptent taciti sociosqu ad
+litora torquent per conubia nostra, per inceptos himenaeos. Fusce
+lobortis velit in elit pellentesque, ut auctor ipsum dignissim. Sed
+aliquet eleifend convallis. Duis mollis, dolor sed rutrum mollis,
+augue eros dignissim erat, eu dapibus augue turpis ac sapien. Morbi at
+volutpat odio, at molestie risus. Nulla quis nulla et magna vestibulum
+euismod. Praesent suscipit quam elit, non luctus turpis rutrum
+faucibus.</p>
+
+<p>Morbi feugiat lacus rhoncus, dignissim velit nec, dignissim
+lorem. Aliquam erat volutpat. Mauris semper dictum tempus. Nulla ex
+ligula, malesuada in ornare sed, euismod vitae massa. Etiam quis erat
+quis nisl facilisis suscipit. Mauris placerat ante et auctor
+fermentum. Donec tincidunt justo sem, ullamcorper vulputate nisl
+commodo a. Vestibulum quis ex non elit porttitor semper eget quis
+tortor. Suspendisse mattis neque non elementum scelerisque. Nulla
+facilisi. Nulla non felis et justo feugiat elementum. Aenean sodales
+turpis at erat eleifend lacinia. Proin eleifend volutpat purus id
+mollis. Proin vel tellus faucibus, sagittis libero at, lobortis
+odio. Praesent quam mauris, auctor vel velit eu, convallis molestie
+nisi. Pellentesque in nunc at orci ultrices vehicula.</p>
+
+<p>Praesent nibh lectus, efficitur sed risus in, rutrum tristique
+arcu. Curabitur non efficitur elit. Phasellus eget odio iaculis,
+molestie dui eget, venenatis erat. Nulla luctus facilisis lectus, nec
+dapibus tortor rhoncus vel. Donec nec arcu elit. Nullam ut faucibus
+purus, sed ultricies diam. Pellentesque at finibus ipsum. Vestibulum
+egestas dignissim nisl, ac rhoncus risus finibus sit amet. Donec non
+feugiat ante. Donec vehicula dui a lorem imperdiet, a tempus diam
+pulvinar. Nullam congue efficitur justo, non posuere ligula sodales
+in. Ut a urna ornare, ultrices velit in, pellentesque
+lorem. Vestibulum ante ipsum primis in faucibus orci luctus et
+ultrices posuere cubilia Curae;</p>
+
+<p>Orci varius natoque penatibus et magnis dis parturient montes,
+nascetur ridiculus mus. Morbi maximus quis magna et aliquet. Nam
+bibendum fermentum tempus. Praesent iaculis tortor metus, at
+vestibulum ipsum hendrerit mattis. Proin fringilla nisl sit amet
+tincidunt blandit. Interdum et malesuada fames ac ante ipsum primis in
+faucibus. Phasellus vel lectus leo. Curabitur fringilla, arcu non
+posuere viverra, urna metus blandit augue, convallis mattis tortor dui
+vel arcu. In sit amet metus vitae ex rhoncus hendrerit.</p>
+
+<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus
+interdum maximus finibus. Ut fermentum malesuada commodo. Sed
+faucibus, sapien a mattis lobortis, magna ante efficitur mauris, quis
+sodales nibh diam nec quam. Vestibulum magna libero, tincidunt non
+erat sed, molestie pulvinar ex. Maecenas semper blandit elit, id
+suscipit nulla venenatis pretium. Integer accumsan porta felis, vitae
+placerat urna accumsan vel. Aliquam eu aliquet mi. Aenean tincidunt
+eros lectus, sit amet efficitur orci ultrices at. Morbi lobortis ex
+quis luctus rutrum. In nulla velit, elementum vitae turpis vitae,
+finibus varius massa. Morbi id libero faucibus, tempus eros et,
+ullamcorper ipsum. Sed eleifend finibus bibendum. Nullam ut nunc nec
+lacus posuere dignissim. Nunc sollicitudin vitae augue id
+vulputate. Ut ac nibh gravida, volutpat est ac, facilisis neque.</p>
+
+<p>Nam dictum leo massa, non posuere dui bibendum id. Morbi sagittis est
+non est laoreet, a sollicitudin felis aliquet. Ut cursus vel leo a
+efficitur. Proin ut pellentesque sapien, vel maximus dui. Suspendisse
+eu felis eget leo elementum efficitur. Class aptent taciti sociosqu ad
+litora torquent per conubia nostra, per inceptos himenaeos. Fusce
+lobortis velit in elit pellentesque, ut auctor ipsum dignissim. Sed
+aliquet eleifend convallis. Duis mollis, dolor sed rutrum mollis,
+augue eros dignissim erat, eu dapibus augue turpis ac sapien. Morbi at
+volutpat odio, at molestie risus. Nulla quis nulla et magna vestibulum
+euismod. Praesent suscipit quam elit, non luctus turpis rutrum
+faucibus.</p>
+
+<p>Morbi feugiat lacus rhoncus, dignissim velit nec, dignissim
+lorem. Aliquam erat volutpat. Mauris semper dictum tempus. Nulla ex
+ligula, malesuada in ornare sed, euismod vitae massa. Etiam quis erat
+quis nisl facilisis suscipit. Mauris placerat ante et auctor
+fermentum. Donec tincidunt justo sem, ullamcorper vulputate nisl
+commodo a. Vestibulum quis ex non elit porttitor semper eget quis
+tortor. Suspendisse mattis neque non elementum scelerisque. Nulla
+facilisi. Nulla non felis et justo feugiat elementum. Aenean sodales
+turpis at erat eleifend lacinia. Proin eleifend volutpat purus id
+mollis. Proin vel tellus faucibus, sagittis libero at, lobortis
+odio. Praesent quam mauris, auctor vel velit eu, convallis molestie
+nisi. Pellentesque in nunc at orci ultrices vehicula.</p>
+
+<p>Praesent nibh lectus, efficitur sed risus in, rutrum tristique
+arcu. Curabitur non efficitur elit. Phasellus eget odio iaculis,
+molestie dui eget, venenatis erat. Nulla luctus facilisis lectus, nec
+dapibus tortor rhoncus vel. Donec nec arcu elit. Nullam ut faucibus
+purus, sed ultricies diam. Pellentesque at finibus ipsum. Vestibulum
+egestas dignissim nisl, ac rhoncus risus finibus sit amet. Donec non
+feugiat ante. Donec vehicula dui a lorem imperdiet, a tempus diam
+pulvinar. Nullam congue efficitur justo, non posuere ligula sodales
+in. Ut a urna ornare, ultrices velit in, pellentesque
+lorem. Vestibulum ante ipsum primis in faucibus orci luctus et
+ultrices posuere cubilia Curae;</p>
+
+<p>Orci varius natoque penatibus et magnis dis parturient montes,
+nascetur ridiculus mus. Morbi maximus quis magna et aliquet. Nam
+bibendum fermentum tempus. Praesent iaculis tortor metus, at
+vestibulum ipsum hendrerit mattis. Proin fringilla nisl sit amet
+tincidunt blandit. Interdum et malesuada fames ac ante ipsum primis in
+faucibus. Phasellus vel lectus leo. Curabitur fringilla, arcu non
+posuere viverra, urna metus blandit augue, convallis mattis tortor dui
+vel arcu. In sit amet metus vitae ex rhoncus hendrerit.</p>
+
+<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus
+interdum maximus finibus. Ut fermentum malesuada commodo. Sed
+faucibus, sapien a mattis lobortis, magna ante efficitur mauris, quis
+sodales nibh diam nec quam. Vestibulum magna libero, tincidunt non
+erat sed, molestie pulvinar ex. Maecenas semper blandit elit, id
+suscipit nulla venenatis pretium. Integer accumsan porta felis, vitae
+placerat urna accumsan vel. Aliquam eu aliquet mi. Aenean tincidunt
+eros lectus, sit amet efficitur orci ultrices at. Morbi lobortis ex
+quis luctus rutrum. In nulla velit, elementum vitae turpis vitae,
+finibus varius massa. Morbi id libero faucibus, tempus eros et,
+ullamcorper ipsum. Sed eleifend finibus bibendum. Nullam ut nunc nec
+lacus posuere dignissim. Nunc sollicitudin vitae augue id
+vulputate. Ut ac nibh gravida, volutpat est ac, facilisis neque.</p>
+
+<p>Nam dictum leo massa, non posuere dui bibendum id. Morbi sagittis est
+non est laoreet, a sollicitudin felis aliquet. Ut cursus vel leo a
+efficitur. Proin ut pellentesque sapien, vel maximus dui. Suspendisse
+eu felis eget leo elementum efficitur. Class aptent taciti sociosqu ad
+litora torquent per conubia nostra, per inceptos himenaeos. Fusce
+lobortis velit in elit pellentesque, ut auctor ipsum dignissim. Sed
+aliquet eleifend convallis. Duis mollis, dolor sed rutrum mollis,
+augue eros dignissim erat, eu dapibus augue turpis ac sapien. Morbi at
+volutpat odio, at molestie risus. Nulla quis nulla et magna vestibulum
+euismod. Praesent suscipit quam elit, non luctus turpis rutrum
+faucibus.</p>
+
+<p>Morbi feugiat lacus rhoncus, dignissim velit nec, dignissim
+lorem. Aliquam erat volutpat. Mauris semper dictum tempus. Nulla ex
+ligula, malesuada in ornare sed, euismod vitae massa. Etiam quis erat
+quis nisl facilisis suscipit. Mauris placerat ante et auctor
+fermentum. Donec tincidunt justo sem, ullamcorper vulputate nisl
+commodo a. Vestibulum quis ex non elit porttitor semper eget quis
+tortor. Suspendisse mattis neque non elementum scelerisque. Nulla
+facilisi. Nulla non felis et justo feugiat elementum. Aenean sodales
+turpis at erat eleifend lacinia. Proin eleifend volutpat purus id
+mollis. Proin vel tellus faucibus, sagittis libero at, lobortis
+odio. Praesent quam mauris, auctor vel velit eu, convallis molestie
+nisi. Pellentesque in nunc at orci ultrices vehicula.</p>
+
+<p>Praesent nibh lectus, efficitur sed risus in, rutrum tristique
+arcu. Curabitur non efficitur elit. Phasellus eget odio iaculis,
+molestie dui eget, venenatis erat. Nulla luctus facilisis lectus, nec
+dapibus tortor rhoncus vel. Donec nec arcu elit. Nullam ut faucibus
+purus, sed ultricies diam. Pellentesque at finibus ipsum. Vestibulum
+egestas dignissim nisl, ac rhoncus risus finibus sit amet. Donec non
+feugiat ante. Donec vehicula dui a lorem imperdiet, a tempus diam
+pulvinar. Nullam congue efficitur justo, non posuere ligula sodales
+in. Ut a urna ornare, ultrices velit in, pellentesque
+lorem. Vestibulum ante ipsum primis in faucibus orci luctus et
+ultrices posuere cubilia Curae;</p>
+
+<p>Orci varius natoque penatibus et magnis dis parturient montes,
+nascetur ridiculus mus. Morbi maximus quis magna et aliquet. Nam
+bibendum fermentum tempus. Praesent iaculis tortor metus, at
+vestibulum ipsum hendrerit mattis. Proin fringilla nisl sit amet
+tincidunt blandit. Interdum et malesuada fames ac ante ipsum primis in
+faucibus. Phasellus vel lectus leo. Curabitur fringilla, arcu non
+posuere viverra, urna metus blandit augue, convallis mattis tortor dui
+vel arcu. In sit amet metus vitae ex rhoncus hendrerit.</p>
+
+<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus
+interdum maximus finibus. Ut fermentum malesuada commodo. Sed
+faucibus, sapien a mattis lobortis, magna ante efficitur mauris, quis
+sodales nibh diam nec quam. Vestibulum magna libero, tincidunt non
+erat sed, molestie pulvinar ex. Maecenas semper blandit elit, id
+suscipit nulla venenatis pretium. Integer accumsan porta felis, vitae
+placerat urna accumsan vel. Aliquam eu aliquet mi. Aenean tincidunt
+eros lectus, sit amet efficitur orci ultrices at. Morbi lobortis ex
+quis luctus rutrum. In nulla velit, elementum vitae turpis vitae,
+finibus varius massa. Morbi id libero faucibus, tempus eros et,
+ullamcorper ipsum. Sed eleifend finibus bibendum. Nullam ut nunc nec
+lacus posuere dignissim. Nunc sollicitudin vitae augue id
+vulputate. Ut ac nibh gravida, volutpat est ac, facilisis neque.</p>
+
+<p>Nam dictum leo massa, non posuere dui bibendum id. Morbi sagittis est
+non est laoreet, a sollicitudin felis aliquet. Ut cursus vel leo a
+efficitur. Proin ut pellentesque sapien, vel maximus dui. Suspendisse
+eu felis eget leo elementum efficitur. Class aptent taciti sociosqu ad
+litora torquent per conubia nostra, per inceptos himenaeos. Fusce
+lobortis velit in elit pellentesque, ut auctor ipsum dignissim. Sed
+aliquet eleifend convallis. Duis mollis, dolor sed rutrum mollis,
+augue eros dignissim erat, eu dapibus augue turpis ac sapien. Morbi at
+volutpat odio, at molestie risus. Nulla quis nulla et magna vestibulum
+euismod. Praesent suscipit quam elit, non luctus turpis rutrum
+faucibus.</p>
+
+<p>Morbi feugiat lacus rhoncus, dignissim velit nec, dignissim
+lorem. Aliquam erat volutpat. Mauris semper dictum tempus. Nulla ex
+ligula, malesuada in ornare sed, euismod vitae massa. Etiam quis erat
+quis nisl facilisis suscipit. Mauris placerat ante et auctor
+fermentum. Donec tincidunt justo sem, ullamcorper vulputate nisl
+commodo a. Vestibulum quis ex non elit porttitor semper eget quis
+tortor. Suspendisse mattis neque non elementum scelerisque. Nulla
+facilisi. Nulla non felis et justo feugiat elementum. Aenean sodales
+turpis at erat eleifend lacinia. Proin eleifend volutpat purus id
+mollis. Proin vel tellus faucibus, sagittis libero at, lobortis
+odio. Praesent quam mauris, auctor vel velit eu, convallis molestie
+nisi. Pellentesque in nunc at orci ultrices vehicula.</p>
+
+<p>Praesent nibh lectus, efficitur sed risus in, rutrum tristique
+arcu. Curabitur non efficitur elit. Phasellus eget odio iaculis,
+molestie dui eget, venenatis erat. Nulla luctus facilisis lectus, nec
+dapibus tortor rhoncus vel. Donec nec arcu elit. Nullam ut faucibus
+purus, sed ultricies diam. Pellentesque at finibus ipsum. Vestibulum
+egestas dignissim nisl, ac rhoncus risus finibus sit amet. Donec non
+feugiat ante. Donec vehicula dui a lorem imperdiet, a tempus diam
+pulvinar. Nullam congue efficitur justo, non posuere ligula sodales
+in. Ut a urna ornare, ultrices velit in, pellentesque
+lorem. Vestibulum ante ipsum primis in faucibus orci luctus et
+ultrices posuere cubilia Curae;</p>
+
+<p>Orci varius natoque penatibus et magnis dis parturient montes,
+nascetur ridiculus mus. Morbi maximus quis magna et aliquet. Nam
+bibendum fermentum tempus. Praesent iaculis tortor metus, at
+vestibulum ipsum hendrerit mattis. Proin fringilla nisl sit amet
+tincidunt blandit. Interdum et malesuada fames ac ante ipsum primis in
+faucibus. Phasellus vel lectus leo. Curabitur fringilla, arcu non
+posuere viverra, urna metus blandit augue, convallis mattis tortor dui
+vel arcu. In sit amet metus vitae ex rhoncus hendrerit.</p>
+
+<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus
+interdum maximus finibus. Ut fermentum malesuada commodo. Sed
+faucibus, sapien a mattis lobortis, magna ante efficitur mauris, quis
+sodales nibh diam nec quam. Vestibulum magna libero, tincidunt non
+erat sed, molestie pulvinar ex. Maecenas semper blandit elit, id
+suscipit nulla venenatis pretium. Integer accumsan porta felis, vitae
+placerat urna accumsan vel. Aliquam eu aliquet mi. Aenean tincidunt
+eros lectus, sit amet efficitur orci ultrices at. Morbi lobortis ex
+quis luctus rutrum. In nulla velit, elementum vitae turpis vitae,
+finibus varius massa. Morbi id libero faucibus, tempus eros et,
+ullamcorper ipsum. Sed eleifend finibus bibendum. Nullam ut nunc nec
+lacus posuere dignissim. Nunc sollicitudin vitae augue id
+vulputate. Ut ac nibh gravida, volutpat est ac, facilisis neque.</p>
+
+<p>Nam dictum leo massa, non posuere dui bibendum id. Morbi sagittis est
+non est laoreet, a sollicitudin felis aliquet. Ut cursus vel leo a
+efficitur. Proin ut pellentesque sapien, vel maximus dui. Suspendisse
+eu felis eget leo elementum efficitur. Class aptent taciti sociosqu ad
+litora torquent per conubia nostra, per inceptos himenaeos. Fusce
+lobortis velit in elit pellentesque, ut auctor ipsum dignissim. Sed
+aliquet eleifend convallis. Duis mollis, dolor sed rutrum mollis,
+augue eros dignissim erat, eu dapibus augue turpis ac sapien. Morbi at
+volutpat odio, at molestie risus. Nulla quis nulla et magna vestibulum
+euismod. Praesent suscipit quam elit, non luctus turpis rutrum
+faucibus.</p>
+
+<p>Morbi feugiat lacus rhoncus, dignissim velit nec, dignissim
+lorem. Aliquam erat volutpat. Mauris semper dictum tempus. Nulla ex
+ligula, malesuada in ornare sed, euismod vitae massa. Etiam quis erat
+quis nisl facilisis suscipit. Mauris placerat ante et auctor
+fermentum. Donec tincidunt justo sem, ullamcorper vulputate nisl
+commodo a. Vestibulum quis ex non elit porttitor semper eget quis
+tortor. Suspendisse mattis neque non elementum scelerisque. Nulla
+facilisi. Nulla non felis et justo feugiat elementum. Aenean sodales
+turpis at erat eleifend lacinia. Proin eleifend volutpat purus id
+mollis. Proin vel tellus faucibus, sagittis libero at, lobortis
+odio. Praesent quam mauris, auctor vel velit eu, convallis molestie
+nisi. Pellentesque in nunc at orci ultrices vehicula.</p>
+
+<p>Praesent nibh lectus, efficitur sed risus in, rutrum tristique
+arcu. Curabitur non efficitur elit. Phasellus eget odio iaculis,
+molestie dui eget, venenatis erat. Nulla luctus facilisis lectus, nec
+dapibus tortor rhoncus vel. Donec nec arcu elit. Nullam ut faucibus
+purus, sed ultricies diam. Pellentesque at finibus ipsum. Vestibulum
+egestas dignissim nisl, ac rhoncus risus finibus sit amet. Donec non
+feugiat ante. Donec vehicula dui a lorem imperdiet, a tempus diam
+pulvinar. Nullam congue efficitur justo, non posuere ligula sodales
+in. Ut a urna ornare, ultrices velit in, pellentesque
+lorem. Vestibulum ante ipsum primis in faucibus orci luctus et
+ultrices posuere cubilia Curae;</p>
+
+<p>Orci varius natoque penatibus et magnis dis parturient montes,
+nascetur ridiculus mus. Morbi maximus quis magna et aliquet. Nam
+bibendum fermentum tempus. Praesent iaculis tortor metus, at
+vestibulum ipsum hendrerit mattis. Proin fringilla nisl sit amet
+tincidunt blandit. Interdum et malesuada fames ac ante ipsum primis in
+faucibus. Phasellus vel lectus leo. Curabitur fringilla, arcu non
+posuere viverra, urna metus blandit augue, convallis mattis tortor dui
+vel arcu. In sit amet metus vitae ex rhoncus hendrerit.</p>
+
+<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus
+interdum maximus finibus. Ut fermentum malesuada commodo. Sed
+faucibus, sapien a mattis lobortis, magna ante efficitur mauris, quis
+sodales nibh diam nec quam. Vestibulum magna libero, tincidunt non
+erat sed, molestie pulvinar ex. Maecenas semper blandit elit, id
+suscipit nulla venenatis pretium. Integer accumsan porta felis, vitae
+placerat urna accumsan vel. Aliquam eu aliquet mi. Aenean tincidunt
+eros lectus, sit amet efficitur orci ultrices at. Morbi lobortis ex
+quis luctus rutrum. In nulla velit, elementum vitae turpis vitae,
+finibus varius massa. Morbi id libero faucibus, tempus eros et,
+ullamcorper ipsum. Sed eleifend finibus bibendum. Nullam ut nunc nec
+lacus posuere dignissim. Nunc sollicitudin vitae augue id
+vulputate. Ut ac nibh gravida, volutpat est ac, facilisis neque.</p>
+
+<p>Nam dictum leo massa, non posuere dui bibendum id. Morbi sagittis est
+non est laoreet, a sollicitudin felis aliquet. Ut cursus vel leo a
+efficitur. Proin ut pellentesque sapien, vel maximus dui. Suspendisse
+eu felis eget leo elementum efficitur. Class aptent taciti sociosqu ad
+litora torquent per conubia nostra, per inceptos himenaeos. Fusce
+lobortis velit in elit pellentesque, ut auctor ipsum dignissim. Sed
+aliquet eleifend convallis. Duis mollis, dolor sed rutrum mollis,
+augue eros dignissim erat, eu dapibus augue turpis ac sapien. Morbi at
+volutpat odio, at molestie risus. Nulla quis nulla et magna vestibulum
+euismod. Praesent suscipit quam elit, non luctus turpis rutrum
+faucibus.</p>
+
+<p>Morbi feugiat lacus rhoncus, dignissim velit nec, dignissim
+lorem. Aliquam erat volutpat. Mauris semper dictum tempus. Nulla ex
+ligula, malesuada in ornare sed, euismod vitae massa. Etiam quis erat
+quis nisl facilisis suscipit. Mauris placerat ante et auctor
+fermentum. Donec tincidunt justo sem, ullamcorper vulputate nisl
+commodo a. Vestibulum quis ex non elit porttitor semper eget quis
+tortor. Suspendisse mattis neque non elementum scelerisque. Nulla
+facilisi. Nulla non felis et justo feugiat elementum. Aenean sodales
+turpis at erat eleifend lacinia. Proin eleifend volutpat purus id
+mollis. Proin vel tellus faucibus, sagittis libero at, lobortis
+odio. Praesent quam mauris, auctor vel velit eu, convallis molestie
+nisi. Pellentesque in nunc at orci ultrices vehicula.</p>
+
+<p>Praesent nibh lectus, efficitur sed risus in, rutrum tristique
+arcu. Curabitur non efficitur elit. Phasellus eget odio iaculis,
+molestie dui eget, venenatis erat. Nulla luctus facilisis lectus, nec
+dapibus tortor rhoncus vel. Donec nec arcu elit. Nullam ut faucibus
+purus, sed ultricies diam. Pellentesque at finibus ipsum. Vestibulum
+egestas dignissim nisl, ac rhoncus risus finibus sit amet. Donec non
+feugiat ante. Donec vehicula dui a lorem imperdiet, a tempus diam
+pulvinar. Nullam congue efficitur justo, non posuere ligula sodales
+in. Ut a urna ornare, ultrices velit in, pellentesque
+lorem. Vestibulum ante ipsum primis in faucibus orci luctus et
+ultrices posuere cubilia Curae;</p>
+
+<p>Orci varius natoque penatibus et magnis dis parturient montes,
+nascetur ridiculus mus. Morbi maximus quis magna et aliquet. Nam
+bibendum fermentum tempus. Praesent iaculis tortor metus, at
+vestibulum ipsum hendrerit mattis. Proin fringilla nisl sit amet
+tincidunt blandit. Interdum et malesuada fames ac ante ipsum primis in
+faucibus. Phasellus vel lectus leo. Curabitur fringilla, arcu non
+posuere viverra, urna metus blandit augue, convallis mattis tortor dui
+vel arcu. In sit amet metus vitae ex rhoncus hendrerit.</p>
+
+<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus
+interdum maximus finibus. Ut fermentum malesuada commodo. Sed
+faucibus, sapien a mattis lobortis, magna ante efficitur mauris, quis
+sodales nibh diam nec quam. Vestibulum magna libero, tincidunt non
+erat sed, molestie pulvinar ex. Maecenas semper blandit elit, id
+suscipit nulla venenatis pretium. Integer accumsan porta felis, vitae
+placerat urna accumsan vel. Aliquam eu aliquet mi. Aenean tincidunt
+eros lectus, sit amet efficitur orci ultrices at. Morbi lobortis ex
+quis luctus rutrum. In nulla velit, elementum vitae turpis vitae,
+finibus varius massa. Morbi id libero faucibus, tempus eros et,
+ullamcorper ipsum. Sed eleifend finibus bibendum. Nullam ut nunc nec
+lacus posuere dignissim. Nunc sollicitudin vitae augue id
+vulputate. Ut ac nibh gravida, volutpat est ac, facilisis neque.</p>
+
+<p>Nam dictum leo massa, non posuere dui bibendum id. Morbi sagittis est
+non est laoreet, a sollicitudin felis aliquet. Ut cursus vel leo a
+efficitur. Proin ut pellentesque sapien, vel maximus dui. Suspendisse
+eu felis eget leo elementum efficitur. Class aptent taciti sociosqu ad
+litora torquent per conubia nostra, per inceptos himenaeos. Fusce
+lobortis velit in elit pellentesque, ut auctor ipsum dignissim. Sed
+aliquet eleifend convallis. Duis mollis, dolor sed rutrum mollis,
+augue eros dignissim erat, eu dapibus augue turpis ac sapien. Morbi at
+volutpat odio, at molestie risus. Nulla quis nulla et magna vestibulum
+euismod. Praesent suscipit quam elit, non luctus turpis rutrum
+faucibus.</p>
+
+<p>Morbi feugiat lacus rhoncus, dignissim velit nec, dignissim
+lorem. Aliquam erat volutpat. Mauris semper dictum tempus. Nulla ex
+ligula, malesuada in ornare sed, euismod vitae massa. Etiam quis erat
+quis nisl facilisis suscipit. Mauris placerat ante et auctor
+fermentum. Donec tincidunt justo sem, ullamcorper vulputate nisl
+commodo a. Vestibulum quis ex non elit porttitor semper eget quis
+tortor. Suspendisse mattis neque non elementum scelerisque. Nulla
+facilisi. Nulla non felis et justo feugiat elementum. Aenean sodales
+turpis at erat eleifend lacinia. Proin eleifend volutpat purus id
+mollis. Proin vel tellus faucibus, sagittis libero at, lobortis
+odio. Praesent quam mauris, auctor vel velit eu, convallis molestie
+nisi. Pellentesque in nunc at orci ultrices vehicula.</p>
+
+<p>Praesent nibh lectus, efficitur sed risus in, rutrum tristique
+arcu. Curabitur non efficitur elit. Phasellus eget odio iaculis,
+molestie dui eget, venenatis erat. Nulla luctus facilisis lectus, nec
+dapibus tortor rhoncus vel. Donec nec arcu elit. Nullam ut faucibus
+purus, sed ultricies diam. Pellentesque at finibus ipsum. Vestibulum
+egestas dignissim nisl, ac rhoncus risus finibus sit amet. Donec non
+feugiat ante. Donec vehicula dui a lorem imperdiet, a tempus diam
+pulvinar. Nullam congue efficitur justo, non posuere ligula sodales
+in. Ut a urna ornare, ultrices velit in, pellentesque
+lorem. Vestibulum ante ipsum primis in faucibus orci luctus et
+ultrices posuere cubilia Curae;</p>
+
+<p>Orci varius natoque penatibus et magnis dis parturient montes,
+nascetur ridiculus mus. Morbi maximus quis magna et aliquet. Nam
+bibendum fermentum tempus. Praesent iaculis tortor metus, at
+vestibulum ipsum hendrerit mattis. Proin fringilla nisl sit amet
+tincidunt blandit. Interdum et malesuada fames ac ante ipsum primis in
+faucibus. Phasellus vel lectus leo. Curabitur fringilla, arcu non
+posuere viverra, urna metus blandit augue, convallis mattis tortor dui
+vel arcu. In sit amet metus vitae ex rhoncus hendrerit.</p>
+
+</body>
+</html>
diff --git a/src/starboard/android/apk/app/src/app/java/dev/cobalt/app/CobaltApplication.java b/src/starboard/android/apk/app/src/app/java/dev/cobalt/app/CobaltApplication.java
new file mode 100644
index 0000000..83f0a8e
--- /dev/null
+++ b/src/starboard/android/apk/app/src/app/java/dev/cobalt/app/CobaltApplication.java
@@ -0,0 +1,35 @@
+// Copyright 2018 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.app;
+
+import android.app.Application;
+import dev.cobalt.coat.StarboardBridge;
+
+/**
+ * Android Application hosting the Starboard application.
+ */
+public class CobaltApplication extends Application implements StarboardBridge.HostApplication {
+  StarboardBridge starboardBridge;
+
+  @Override
+  public void setStarboardBridge(StarboardBridge starboardBridge) {
+    this.starboardBridge = starboardBridge;
+  }
+
+  @Override
+  public StarboardBridge getStarboardBridge() {
+    return starboardBridge;
+  }
+}
diff --git a/src/starboard/android/apk/app/src/app/java/dev/cobalt/app/MainActivity.java b/src/starboard/android/apk/app/src/app/java/dev/cobalt/app/MainActivity.java
new file mode 100644
index 0000000..04e70a8
--- /dev/null
+++ b/src/starboard/android/apk/app/src/app/java/dev/cobalt/app/MainActivity.java
@@ -0,0 +1,53 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.app;
+
+import android.app.Activity;
+import dev.cobalt.account.UserAuthorizerImpl;
+import dev.cobalt.coat.CobaltActivity;
+import dev.cobalt.coat.StarboardBridge;
+import dev.cobalt.feedback.NoopFeedbackService;
+import dev.cobalt.util.Holder;
+
+/**
+ * Main Activity for the "Cobalt on Android TV" app.
+ *
+ * <p>The real work is done in the abstract base class. This class is really just some factory
+ * methods to "inject" things that can be customized.
+ */
+public class MainActivity extends CobaltActivity {
+
+  @Override
+  protected StarboardBridge createStarboardBridge(String[] args, String startDeepLink) {
+    Holder<Activity> activityHolder = new Holder<>();
+    Runnable stopRequester =
+        new Runnable() {
+          @Override
+          public void run() {
+            getStarboardBridge().requestStop(0);
+          }
+        };
+    UserAuthorizerImpl userAuthorizer =
+        new UserAuthorizerImpl(getApplicationContext(), activityHolder, stopRequester);
+    NoopFeedbackService feedbackService = new NoopFeedbackService();
+    return new StarboardBridge(
+        getApplicationContext(),
+        activityHolder,
+        userAuthorizer,
+        feedbackService,
+        args,
+        startDeepLink);
+  }
+}
diff --git a/src/starboard/android/apk/app/src/app/res/drawable-xhdpi/app_banner.png b/src/starboard/android/apk/app/src/app/res/drawable-xhdpi/app_banner.png
new file mode 100644
index 0000000..fd47a4d
--- /dev/null
+++ b/src/starboard/android/apk/app/src/app/res/drawable-xhdpi/app_banner.png
Binary files differ
diff --git a/src/starboard/android/apk/app/src/app/res/mipmap-hdpi/ic_app.png b/src/starboard/android/apk/app/src/app/res/mipmap-hdpi/ic_app.png
new file mode 100644
index 0000000..44469e7
--- /dev/null
+++ b/src/starboard/android/apk/app/src/app/res/mipmap-hdpi/ic_app.png
Binary files differ
diff --git a/src/starboard/android/apk/app/src/app/res/mipmap-mdpi/ic_app.png b/src/starboard/android/apk/app/src/app/res/mipmap-mdpi/ic_app.png
new file mode 100644
index 0000000..8cd39ca
--- /dev/null
+++ b/src/starboard/android/apk/app/src/app/res/mipmap-mdpi/ic_app.png
Binary files differ
diff --git a/src/starboard/android/apk/app/src/app/res/mipmap-xhdpi/ic_app.png b/src/starboard/android/apk/app/src/app/res/mipmap-xhdpi/ic_app.png
new file mode 100644
index 0000000..4e12e56
--- /dev/null
+++ b/src/starboard/android/apk/app/src/app/res/mipmap-xhdpi/ic_app.png
Binary files differ
diff --git a/src/starboard/android/apk/app/src/app/res/mipmap-xxhdpi/ic_app.png b/src/starboard/android/apk/app/src/app/res/mipmap-xxhdpi/ic_app.png
new file mode 100644
index 0000000..3948b1e
--- /dev/null
+++ b/src/starboard/android/apk/app/src/app/res/mipmap-xxhdpi/ic_app.png
Binary files differ
diff --git a/src/starboard/android/apk/app/src/app/res/values/strings.xml b/src/starboard/android/apk/app/src/app/res/values/strings.xml
new file mode 100644
index 0000000..4b54b94
--- /dev/null
+++ b/src/starboard/android/apk/app/src/app/res/values/strings.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2017 The Cobalt Authors. All Rights Reserved.
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+
+<resources>
+  <string name="app_name">Cobalt On Android TV</string>
+</resources>
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/account/AccessToken.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/account/AccessToken.java
new file mode 100644
index 0000000..9139b43
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/account/AccessToken.java
@@ -0,0 +1,49 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.account;
+
+import dev.cobalt.util.UsedByNative;
+
+/**
+ * POJO holding the token for a signed-in user.
+ */
+public class AccessToken {
+
+  private final String tokenValue;
+  private final long expirySeconds;
+
+  public AccessToken(String tokenValue, long expirySeconds) {
+    this.tokenValue = tokenValue;
+    this.expirySeconds = expirySeconds;
+  }
+
+  /**
+   * Returns the token value.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public String getTokenValue() {
+    return tokenValue;
+  }
+
+  /**
+   * Returns number of seconds since epoch when this token expires, or 0 if it doesn't expire.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public long getExpirySeconds() {
+    return expirySeconds;
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/account/NoopUserAuthorizer.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/account/NoopUserAuthorizer.java
new file mode 100644
index 0000000..e995dd2
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/account/NoopUserAuthorizer.java
@@ -0,0 +1,59 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.account;
+
+import android.content.Intent;
+import dev.cobalt.util.UsedByNative;
+
+/** UserAuthorizer implementation that doesn't try to sign in. */
+public class NoopUserAuthorizer implements UserAuthorizer {
+
+  @Override
+  public void shutdown() {}
+
+  @Override
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public void interrupt() {}
+
+  @Override
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public AccessToken authorizeUser() {
+    return null;
+  }
+
+  @Override
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public boolean deauthorizeUser() {
+    return false;
+  }
+
+  @Override
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public AccessToken refreshAuthorization() {
+    return null;
+  }
+
+  @Override
+  public void onActivityResult(int requestCode, int resultCode, Intent data) {}
+
+  @Override
+  public void onRequestPermissionsResult(
+      int requestCode, String[] permissions, int[] grantResults) {}
+
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/account/UserAuthorizer.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/account/UserAuthorizer.java
new file mode 100644
index 0000000..1fc849e
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/account/UserAuthorizer.java
@@ -0,0 +1,86 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.account;
+
+import android.content.Intent;
+import dev.cobalt.util.UsedByNative;
+
+/**
+ * Java side implementation for starboard::android::shared::cobalt::AndroidUserAuthorizer.
+ */
+public interface UserAuthorizer {
+
+  /**
+   * Cleans up at the end of the object's lifecycle.
+   */
+  void shutdown();
+
+  /**
+   * Unblocks any pending request.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  void interrupt();
+
+  /**
+   * Prompts the user as necessary to choose an account, then gets an auth token for the selected
+   * account. I.e., pairs the web app to be signed-in as a selected account.
+   *
+   * This method blocks until finished, and must be called off the UI thread.
+   *
+   * Implementations must annotate this method with @UsedByNative so Proguard doesn't remove it.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  AccessToken authorizeUser();
+
+  /**
+   * Prompts the user as necessary to stop using an account. i.e., unpairs the web app to be
+   * signed-out.
+   *
+   * This method blocks until finished, and must be called off the UI thread.
+   *
+   * Implementations must annotate this method with @UsedByNative so Proguard doesn't remove it.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  boolean deauthorizeUser();
+
+  /**
+   * Gets a new auth token for the account most recently authorized.
+   *
+   * No UI will be shown if there is already a current account restored at construction or selected
+   * in authorizeUser(). If there is no current account, or the restored account has been deleted,
+   * the account picker may be shown to let the user specify which account should become the current
+   * account.
+   *
+   * This method blocks until finished, and must be called off the UI thread.
+   *
+   * Implementations must annotate this method with @UsedByNative so Proguard doesn't remove it.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  AccessToken refreshAuthorization();
+
+  /**
+   * @see android.app.Activity#onActivityResult(int, int, Intent)
+   */
+  void onActivityResult(int requestCode, int resultCode, Intent data);
+
+  /**
+   * @see android.app.Activity#onRequestPermissionsResult(int, String[], int[])
+   */
+  void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults);
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/account/UserAuthorizerImpl.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/account/UserAuthorizerImpl.java
new file mode 100644
index 0000000..136da8b
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/account/UserAuthorizerImpl.java
@@ -0,0 +1,532 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.account;
+
+import static android.Manifest.permission.GET_ACCOUNTS;
+import static dev.cobalt.util.Log.TAG;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.OnAccountsUpdateListener;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.v4.app.ActivityCompat;
+import android.support.v4.content.ContextCompat;
+import android.text.TextUtils;
+import android.widget.Toast;
+import com.google.android.gms.auth.GoogleAuthException;
+import com.google.android.gms.auth.GoogleAuthUtil;
+import com.google.android.gms.auth.UserRecoverableAuthException;
+import com.google.android.gms.common.AccountPicker;
+import dev.cobalt.coat.R;
+import dev.cobalt.util.Holder;
+import dev.cobalt.util.Log;
+import dev.cobalt.util.UsedByNative;
+import java.io.IOException;
+
+/**
+ * Java side implementation for starboard::android::shared::cobalt::AndroidUserAuthorizer.
+ *
+ * This implements the following business logic:
+ * First run...
+ * - if there are no accounts, just be be signed-out
+ * - if there is one account, sign-in without any UI
+ * - if there are more than one accounts, prompt to choose account
+ * Subsequent runs...
+ * - sign-in to the same account last used to sign-in
+ * - if previously signed-out stay signed-out
+ * When user clicks 'sign-in' in the UI...
+ * - if there are no accounts, allow user to add an account
+ * - if there is one account, sign-in without any UI
+ * - if there are more than one accounts, prompt to choose account
+ * If the last signed-in account is deleted...
+ * - kill the app if stopped in the background to prompt next time it starts
+ * - at the next app start, show a toast that the account isn't available
+ * - if there are no accounts left, just be be signed-out
+ * - if there are one or more accounts left, prompt to choose account
+ */
+public class UserAuthorizerImpl implements OnAccountsUpdateListener, UserAuthorizer {
+
+  /** Pseudo account indicating the user chose to be signed-out. */
+  public static final Account SIGNED_OUT_ACCOUNT = new Account("-", "-");
+
+  /** Pseudo account indicating a saved account no longer exists. */
+  private static final Account MISSING_ACCOUNT = new Account("!", "!");
+
+  /** Foreshortened expiry of Google OAuth token, which typically lasts 1 hour. */
+  private static final long DEFAULT_EXPIRY_SECONDS = 5 * 60;
+
+  private static final String GOOGLE_ACCOUNT_TYPE = "com.google";
+  private static final String[] OAUTH_SCOPES = {
+      "https://www.googleapis.com/auth/youtube"
+  };
+
+  private static final String SHARED_PREFS_NAME = "user_auth";
+  private static final String ACCOUNT_NAME_PREF_KEY = "signed_in_account";
+
+  /** The thread on which the current request is running, or null if none. */
+  private volatile Thread requestThread;
+
+  private final Context appContext;
+  private final Holder<Activity> activityHolder;
+  private final Runnable stopRequester;
+  private final Handler mainHandler;
+
+  private Account currentAccount = null;
+  private AccessToken currentToken = null;
+
+  // Result from the account picker UI lands here.
+  private String chosenAccountName;
+
+  private volatile boolean waitingForPermission;
+  private volatile boolean permissionGranted;
+
+  public UserAuthorizerImpl(
+      Context appContext, Holder<Activity> activityHolder, Runnable stopRequester) {
+    this.appContext = appContext;
+    this.activityHolder = activityHolder;
+    this.stopRequester = stopRequester;
+    this.mainHandler = new Handler(Looper.getMainLooper());
+    addOnAccountsUpdatedListener(this);
+  }
+
+  @Override
+  public void shutdown() {
+    removeOnAccountsUpdatedListener(this);
+  }
+
+  @Override
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public void interrupt() {
+    Thread t = requestThread;
+    if (t != null) {
+      t.interrupt();
+    }
+  }
+
+  @Override
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public AccessToken authorizeUser() {
+    ensureBackgroundThread();
+    requestThread = Thread.currentThread();
+    // Let the user choose an account, or add one if there are none to choose.
+    // However, if there's only one account just choose it without any prompt.
+    currentAccount = autoSelectOrAddAccount();
+    writeAccountPref(currentAccount);
+    AccessToken accessToken = refreshCurrentToken();
+    requestThread = null;
+    return accessToken;
+  }
+
+  @Override
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public boolean deauthorizeUser() {
+    ensureBackgroundThread();
+    requestThread = Thread.currentThread();
+    currentAccount = SIGNED_OUT_ACCOUNT;
+    writeAccountPref(currentAccount);
+    clearCurrentToken();
+    requestThread = null;
+    return true;
+  }
+
+  @Override
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public AccessToken refreshAuthorization() {
+    ensureBackgroundThread();
+    requestThread = Thread.currentThread();
+
+    // If we haven't yet determined which account to use, check preferences for a saved account.
+    if (currentAccount == null) {
+      Account savedAccount = readAccountPref();
+      if (savedAccount == null) {
+        // No saved account, so this is the first ever run of the app.
+        currentAccount = autoSelectAccount();
+      } else if (savedAccount.equals(MISSING_ACCOUNT)) {
+        // The saved account got deleted.
+        currentAccount = forceSelectAccount();
+      } else {
+        // Use the saved account.
+        currentAccount = savedAccount;
+      }
+      writeAccountPref(currentAccount);
+    }
+
+    AccessToken accessToken = refreshCurrentToken();
+    requestThread = null;
+    return accessToken;
+  }
+
+  private static void ensureBackgroundThread() {
+    if (Looper.myLooper() == Looper.getMainLooper()) {
+      throw new UnsupportedOperationException("UserAuthorizer can't be called on main thread");
+    }
+  }
+
+  private void showToast(int resId, Object... formatArgs) {
+    final String msg = appContext.getResources().getString(resId, formatArgs);
+    mainHandler.post(new Runnable() {
+      @Override
+      public void run() {
+        Toast.makeText(appContext, msg, Toast.LENGTH_LONG).show();
+      }
+    });
+  }
+
+  private Account readAccountPref() {
+    String savedAccountName = loadSignedInAccountName();
+    if (TextUtils.isEmpty(savedAccountName)) {
+      return null;
+    } else if (savedAccountName.equals(SIGNED_OUT_ACCOUNT.name)) {
+      // Don't request permissions or look for a device account if we were signed-out.
+      return SIGNED_OUT_ACCOUNT;
+    } else if (!checkPermission()) {
+      // We won't be able to get the account without permission, so warn the user and be signed-out.
+      showToast(R.string.starboard_missing_account, savedAccountName);
+      return SIGNED_OUT_ACCOUNT;
+    } else {
+      // Find the saved account name among all accounts on the device.
+      for (Account account : getAccounts()) {
+        if (account.name.equals(savedAccountName)) {
+          return account;
+        }
+      }
+      showToast(R.string.starboard_missing_account, savedAccountName);
+      return MISSING_ACCOUNT;
+    }
+  }
+
+  private void writeAccountPref(Account account) {
+    if (account == null) {
+      return;
+    }
+    // Always write the account name, even if it's the signed-out pseudo account.
+    saveSignedInAccountName(account.name);
+  }
+
+  private void clearCurrentToken() {
+    if (currentToken != null) {
+      clearToken(currentToken.getTokenValue());
+      currentToken = null;
+    }
+  }
+
+  private AccessToken refreshCurrentToken() {
+    clearCurrentToken();
+    if (currentAccount == null || SIGNED_OUT_ACCOUNT.equals(currentAccount)) {
+      return null;
+    }
+    String tokenValue = getToken(currentAccount);
+    if (tokenValue == null) {
+      showToast(R.string.starboard_account_auth_error);
+      tokenValue = "";
+    }
+    // TODO: Get the token details and use the actual expiry.
+    long expiry = System.currentTimeMillis() / 1000 + DEFAULT_EXPIRY_SECONDS;
+    currentToken = new AccessToken(tokenValue, expiry);
+    return currentToken;
+  }
+
+  /**
+   * Prompts the user to select an account, or to add an account if there are none. The prompt is
+   * skipped if there is exactly one account to choose from.
+   */
+  private Account autoSelectOrAddAccount() {
+    if (!checkPermission()) {
+      return SIGNED_OUT_ACCOUNT;
+    }
+    Account[] accounts = getAccounts();
+    if (accounts.length == 1) {
+      return accounts[0];
+    }
+    return selectOrAddAccount();
+  }
+
+  /**
+   * Prompts the user to select an account. The prompt is skipped if there are zero or one accounts
+   * to choose from.
+   */
+  private Account autoSelectAccount() {
+    if (!checkPermission()) {
+      return SIGNED_OUT_ACCOUNT;
+    }
+    Account[] accounts = getAccounts();
+    if (accounts.length == 0) {
+      return SIGNED_OUT_ACCOUNT;
+    } else if (accounts.length == 1) {
+      return accounts[0];
+    }
+    return selectOrAddAccount();
+  }
+
+  /**
+   * Prompts the user to select an account, even if there's only one to choose from. The prompt is
+   * skipped if there are zero accounts to choose from.
+   */
+  private Account forceSelectAccount() {
+    // We don't check permissions before calling selectOrAddAccount() because if the account is
+    // missing, readAccountPref() must have just checked, and we don't want to show permission
+    // prompt or rationale to the user twice.
+    Account[] accounts = getAccounts();
+    if (accounts.length == 0) {
+      return SIGNED_OUT_ACCOUNT;
+    }
+    return selectOrAddAccount();
+  }
+
+  /**
+   * Prompts the user to select an account, even if there's only one to choose from. If there are
+   * zero accounts to choose from, the user is prompted to add one.
+   *
+   * The caller should ensure permissions are granted before calling this method to avoid showing
+   * a picker with accounts that we can't access.
+   */
+  private Account selectOrAddAccount() {
+    String accountName = showAccountPicker();
+    // If user cancelled the picker stay signed-out.
+    if (TextUtils.isEmpty(accountName)) {
+      return SIGNED_OUT_ACCOUNT;
+    }
+    // Get the accounts after the picker in case one was added in the account picker.
+    for (Account account : getAccounts()) {
+      if (account.name.equals(accountName)) {
+        return account;
+      }
+    }
+    // This shouldn't happen, but if it does let the user know we're still signed-out.
+    Log.e(TAG, "Selected account is missing");
+    showToast(R.string.starboard_missing_account, accountName);
+    return SIGNED_OUT_ACCOUNT;
+  }
+
+  private synchronized String showAccountPicker() {
+    Activity activity = activityHolder.get();
+    Intent chooseAccountIntent = newChooseAccountIntent(currentAccount);
+    if (activity == null || chooseAccountIntent == null) {
+      return "";
+    }
+    chosenAccountName = null;
+    activity.startActivityForResult(chooseAccountIntent, R.id.rc_choose_account);
+
+    // Block until the account picker activity returns its result.
+    while (chosenAccountName == null) {
+      try {
+        wait();
+      } catch (InterruptedException e) {
+        Log.e(TAG, "Account picker interrupted");
+        // Return empty string, as if the picker was cancelled.
+        return "";
+      }
+    }
+    return chosenAccountName;
+  }
+
+  @Override
+  public void onActivityResult(int requestCode, int resultCode, Intent data) {
+    if (requestCode == R.id.rc_choose_account) {
+      String accountName = null;
+      if (resultCode == Activity.RESULT_OK) {
+        accountName = data.getStringExtra(AccountManager.KEY_ACCOUNT_NAME);
+      } else if (resultCode != Activity.RESULT_CANCELED) {
+        Log.e(TAG, "Account picker error " + resultCode);
+        showToast(R.string.starboard_account_picker_error);
+      }
+
+      // Notify showAccountPicker() which account was chosen.
+      synchronized (this) {
+        // Return empty string if the picker is cancelled or there's an unexpected result.
+        chosenAccountName = (accountName == null) ? "" : accountName;
+        notifyAll();
+      }
+    }
+  }
+
+  @Override
+  public void onAccountsUpdated(Account[] unused) {
+    if (currentAccount == null || SIGNED_OUT_ACCOUNT.equals(currentAccount)) {
+      // We're not signed-in; the update doesn't affect us.
+      return;
+    }
+    // Call getAccounts() since the param may not match the accounts we can access.
+    for (Account account : getAccounts()) {
+      if (account.name.equals(currentAccount.name)) {
+        // The current account is still there; the update doesn't affect us.
+        return;
+      }
+    }
+    // The current account is gone; leave the app so we prompt for sign-in next time.
+    // This should only happen while stopped in the background since we don't delete accounts.
+    stopRequester.run();
+  }
+
+  /**
+   * Calls framework AccountManager.addOnAccountsUpdatedListener().
+   */
+  private void addOnAccountsUpdatedListener(OnAccountsUpdateListener listener) {
+    AccountManager.get(appContext).addOnAccountsUpdatedListener(listener, null, false);
+  }
+
+  /**
+   * Calls framework AccountManager.removeOnAccountsUpdatedListener().
+   */
+  private void removeOnAccountsUpdatedListener(OnAccountsUpdateListener listener) {
+    AccountManager.get(appContext).removeOnAccountsUpdatedListener(listener);
+  }
+
+  /**
+   * Calls framework AccountManager.getAccountsByType() for Google accounts.
+   */
+  private Account[] getAccounts() {
+    return AccountManager.get(appContext).getAccountsByType(GOOGLE_ACCOUNT_TYPE);
+  }
+
+  /**
+   * Calls GMS AccountPicker.newChooseAccountIntent().
+   *
+   * Returns an Intent that when started will always show the account picker even if there's just
+   * one account on the device. If there are no accounts on the device it shows the UI to add one.
+   */
+  private Intent newChooseAccountIntent(Account defaultAccount) {
+    String[] allowableAccountTypes = {GOOGLE_ACCOUNT_TYPE};
+    return AccountPicker.newChooseAccountIntent(
+        defaultAccount, null, allowableAccountTypes, true, null, null, null, null);
+  }
+
+  /**
+   * Calls GMS GoogleAuthUtil.getToken(), without throwing any exceptions.
+   *
+   * Returns an empty string if no token is available for the account.
+   * Returns null if there was an error getting the token.
+   */
+  private String getToken(Account account) {
+    String joinedScopes = "oauth2:" + TextUtils.join(" ", OAUTH_SCOPES);
+    try {
+      return GoogleAuthUtil.getToken(appContext, account, joinedScopes);
+    } catch (UserRecoverableAuthException e) {
+        Log.w(TAG, "Recoverable error getting OAuth token", e);
+        Intent intent = e.getIntent();
+        Activity activity = activityHolder.get();
+        if (intent != null && activity != null) {
+          activity.startActivity(intent);
+        } else {
+          Log.e(TAG, "Failed to recover OAuth token", e);
+        }
+        return null;
+
+    } catch (IOException | GoogleAuthException e) {
+      Log.e(TAG, "Error getting auth token", e);
+      return null;
+    }
+  }
+
+  /**
+   * Calls GMS GoogleAuthUtil.clearToken(), without throwing any exceptions.
+   */
+  private void clearToken(String tokenValue) {
+    try {
+      GoogleAuthUtil.clearToken(appContext, tokenValue);
+    } catch (GoogleAuthException | IOException e) {
+      Log.e(TAG, "Error clearing auth token", e);
+    }
+  }
+
+  /**
+   * Checks whether the app has necessary permissions, asking for them if needed.
+   *
+   * This blocks until permissions are granted/declined, and should not be called on the UI thread.
+   *
+   * Returns true if permissions are granted.
+   */
+  private synchronized boolean checkPermission() {
+    if (ContextCompat.checkSelfPermission(appContext, GET_ACCOUNTS)
+        == PackageManager.PERMISSION_GRANTED) {
+      return true;
+    }
+
+    final Activity activity = activityHolder.get();
+    if (activity == null) {
+      return false;
+    }
+
+    // Check if we have previously been denied permission.
+    if (ActivityCompat.shouldShowRequestPermissionRationale(activity, GET_ACCOUNTS)) {
+      activity.runOnUiThread(new Runnable() {
+        @Override
+        public void run() {
+          Toast.makeText(activity, R.string.starboard_accounts_permission, Toast.LENGTH_LONG)
+              .show();
+        }
+      });
+      return false;
+    }
+
+    // Request permission.
+    waitingForPermission = true;
+    permissionGranted = false;
+    ActivityCompat.requestPermissions(
+        activity, new String[]{GET_ACCOUNTS}, R.id.rc_get_accounts_permission);
+    try {
+      while (waitingForPermission) {
+        wait();
+      }
+    } catch (InterruptedException e) {
+      return false;
+    }
+    return permissionGranted;
+  }
+
+  /**
+   * Callback pass-thru from the Activity with the result from requesting permissions.
+   */
+  @Override
+  public void onRequestPermissionsResult(
+      int requestCode, String[] permissions, int[] grantResults) {
+    if (requestCode == R.id.rc_get_accounts_permission) {
+      synchronized (this) {
+        permissionGranted = grantResults.length > 0
+            && grantResults[0] == PackageManager.PERMISSION_GRANTED;
+        waitingForPermission = false;
+        notifyAll();
+      }
+    }
+  }
+
+  /**
+   * Remember the name of the signed-in account.
+   */
+  private void saveSignedInAccountName(String accountName) {
+    getPreferences().edit().putString(ACCOUNT_NAME_PREF_KEY, accountName).commit();
+  }
+
+  /**
+   * Returns the remembered name of the signed-in account.
+   */
+  private String loadSignedInAccountName() {
+    return getPreferences().getString(ACCOUNT_NAME_PREF_KEY, "");
+  }
+
+  private SharedPreferences getPreferences() {
+    return appContext.getSharedPreferences(SHARED_PREFS_NAME, 0);
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/AudioPermissionRequester.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/AudioPermissionRequester.java
new file mode 100644
index 0000000..f8640d8
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/AudioPermissionRequester.java
@@ -0,0 +1,82 @@
+// Copyright 2018 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.coat;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.support.v4.app.ActivityCompat;
+import android.support.v4.content.ContextCompat;
+import dev.cobalt.util.Holder;
+import dev.cobalt.util.UsedByNative;
+
+/** Helper class that requests the record audio permission. */
+public class AudioPermissionRequester {
+  private final Context context;
+  private final Holder<Activity> activityHolder;
+  private long nativePermissionRequestor;
+  // Only use in synchronized methods.
+  private boolean requestAudioPermissionStarted;
+
+  public AudioPermissionRequester(Context context, Holder<Activity> activityHolder) {
+    this.context = context;
+    this.activityHolder = activityHolder;
+  }
+
+  /**
+   * Requests the RECORD_AUDIO permission. Returns true if the permission is granted; returns false
+   * if the permission is not granted yet and starts to request the RECORD_AUDIO permission.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public synchronized boolean requestRecordAudioPermission(long nativePermissionRequestor) {
+    this.nativePermissionRequestor = nativePermissionRequestor;
+    Activity activity = activityHolder.get();
+    if (activity == null) {
+      return false;
+    }
+
+    if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO)
+        == PackageManager.PERMISSION_GRANTED) {
+      return true;
+    }
+    if (!requestAudioPermissionStarted) {
+      ActivityCompat.requestPermissions(
+          activity, new String[] {Manifest.permission.RECORD_AUDIO}, R.id.rc_record_audio);
+      requestAudioPermissionStarted = true;
+    }
+
+    return false;
+  }
+
+  /** Handles the RECORD_AUDIO request result. */
+  public synchronized void onRequestPermissionsResult(
+      int requestCode, String[] permissions, int[] grantResults) {
+    if (requestCode == R.id.rc_record_audio) {
+      // If the request is cancelled, the result arrays are empty.
+      if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+        // Permission granted.
+        nativeHandlePermission(nativePermissionRequestor, true);
+      } else {
+        // Permission denied.
+        nativeHandlePermission(nativePermissionRequestor, false);
+      }
+      requestAudioPermissionStarted = false;
+    }
+  }
+
+  private native void nativeHandlePermission(long nativePermissionRequestor, boolean isGranted);
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/CobaltA11yHelper.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/CobaltA11yHelper.java
new file mode 100644
index 0000000..8c07518
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/CobaltA11yHelper.java
@@ -0,0 +1,261 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.coat;
+
+import static dev.cobalt.util.Log.TAG;
+
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.support.v4.widget.ExploreByTouchHelper;
+import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+import dev.cobalt.util.Log;
+import java.util.BitSet;
+import java.util.List;
+
+/**
+ * An ExploreByTouchHelper that create a virtual d-pad grid, so that Cobalt remains functional when
+ * the TalkBack screen reader is enabled (which otherwise intercepts d-pad events for most
+ * applications).
+ */
+class CobaltA11yHelper extends ExploreByTouchHelper {
+  // These are from starboard/key.h
+  private static final int SB_KEY_GAMEPAD_DPAD_UP = 0x800C;
+  private static final int SB_KEY_GAMEPAD_DPAD_DOWN = 0x800D;
+  private static final int SB_KEY_GAMEPAD_DPAD_LEFT = 0x800E;
+  private static final int SB_KEY_GAMEPAD_DPAD_RIGHT = 0x800F;
+
+  // The fake dimensions for the nine virtual views.
+  // These values are arbitrary as long as the views stay on the screen.
+  private static final int FAKE_VIEW_HEIGHT = 10;
+  private static final int FAKE_VIEW_WIDTH = 10;
+
+  private int previousFocusedViewId = 1;
+  // This set tracks whether onPopulateNodeForVirtualView has been
+  // called for each virtual view id.
+  private final BitSet nodePopulatedSet = new BitSet(9);
+  private final Handler handler = new Handler();
+  private boolean hasInitialFocusBeenSet;
+
+  public CobaltA11yHelper(View view) {
+    super(view);
+    ViewCompat.setAccessibilityDelegate(view, this);
+  }
+
+  private static native void nativeInjectKeyEvent(int key);
+
+  @Override
+  protected int getVirtualViewAt(float x, float y) {
+    // This method is only required for touch or mouse interfaces.
+    // Since we don't support either, we simply always return HOST_ID.
+    return HOST_ID;
+  }
+
+  @Override
+  protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
+    if (!virtualViewIds.isEmpty()) {
+      throw new RuntimeException("Expected empty list");
+    }
+    // We always have precisely 9 virtual views.
+    for (int i = 1; i <= 9; i++) {
+      virtualViewIds.add(i);
+    }
+  }
+
+  /**
+   * Returns the "patch number" for a given view id, given a focused view id.
+   *
+   * <p>A "patch number" is a 1-9 number that describes where the requestedViewId is now located on
+   * an X-Y grid, given the focusedViewId.
+   *
+   * <p>Patch number grid:
+   * (0,0)----->X
+   *   |+-+-+-+
+   *   ||1|2|3|
+   *   |+-+-+-|
+   *   ||4|5|6|
+   *   |+-+-+-|
+   *   ||7|8|9|
+   *   |+-+-+-+
+   *  \./ Y
+   *
+   * <p>As focus changes, the locations of the views are moved so the focused view is always in the
+   * middle (patch number 5) and all of the other views always in the same relative position with
+   * respect to each other (with those on the edges adjacent to those on the opposite edges --
+   * wrapping around).
+   *
+   * <p>5 is returned whenever focusedViewId = requestedViewId
+   */
+  private static int getPatchNumber(int focusedViewId, int requestedViewId) {
+    // The (x,y) the focused view has in the 9 patch where 5 is in the middle.
+    int focusedX = (focusedViewId - 1) % 3;
+    int focusedY = (focusedViewId - 1) / 3;
+
+    // x and y offsets of focused view where middle is (0, 0)
+    int focusedRelativeToCenterX = focusedX - 1;
+    int focusedRelativeToCenterY = focusedY - 1;
+
+    // The (x,y) the requested view has in the 9 patch where 5 is in the middle.
+    int requestedX = (requestedViewId - 1) % 3;
+    int requestedY = (requestedViewId - 1) / 3;
+
+    // x and y offsets of requested view where middle is (0, 0)
+    int requestedRelativeToCenterX = requestedX - 1;
+    int requestedRelativeToCenterY = requestedY - 1;
+
+    // The (x,y) that the requested view has in the 9 patch when focusedViewId
+    // is in the middle.
+    int translatedRequestedX = (1 + 3 + requestedRelativeToCenterX - focusedRelativeToCenterX) % 3;
+    int translatedRequestedY = (1 + 3 + requestedRelativeToCenterY - focusedRelativeToCenterY) % 3;
+
+    return (translatedRequestedY * 3) + translatedRequestedX + 1;
+  }
+
+  private void maybeInjectEvent(int currentFocusedViewId) {
+    switch (getPatchNumber(previousFocusedViewId, currentFocusedViewId)) {
+      case 5:
+        // no move;
+        break;
+      case 2:
+        nativeInjectKeyEvent(SB_KEY_GAMEPAD_DPAD_UP);
+        break;
+      case 4:
+        nativeInjectKeyEvent(SB_KEY_GAMEPAD_DPAD_LEFT);
+        break;
+      case 6:
+        nativeInjectKeyEvent(SB_KEY_GAMEPAD_DPAD_RIGHT);
+        break;
+      case 8:
+        nativeInjectKeyEvent(SB_KEY_GAMEPAD_DPAD_DOWN);
+        break;
+      default:
+        // TODO: Could support diagonal movements, although it's likely
+        // not possible to reach this.
+        break;
+    }
+    previousFocusedViewId = currentFocusedViewId;
+  }
+
+  @Override
+  protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfoCompat node) {
+    int focusedViewId = getAccessibilityFocusedVirtualViewId();
+
+    if (focusedViewId < 1 || focusedViewId > 9) {
+      // If this is not one of our nine-patch views, it's probably HOST_ID
+      // In any case, assume there is no focus change.
+      focusedViewId = previousFocusedViewId;
+    }
+
+    // onPopulateNodeForVirtualView() gets called at least once every
+    // time the focused view changes. So see if it's changed since the
+    // last time we've been called and inject an event if so.
+    maybeInjectEvent(focusedViewId);
+
+    int patchNumber = getPatchNumber(focusedViewId, virtualViewId);
+
+    int x = (patchNumber - 1) % 3;
+    int y = (patchNumber - 1) / 3;
+
+    // Note that the specific bounds here are arbitrary. The importance
+    // is the relative bounds to each other.
+    node.setBoundsInParent(new Rect(
+        x * FAKE_VIEW_WIDTH,
+        y * FAKE_VIEW_HEIGHT,
+        x * FAKE_VIEW_WIDTH + FAKE_VIEW_WIDTH,
+        y * FAKE_VIEW_HEIGHT + FAKE_VIEW_HEIGHT));
+    node.setText("");
+
+    if (virtualViewId >= 1 || virtualViewId <= 9) {
+      nodePopulatedSet.set(virtualViewId - 1);
+    }
+    if (!hasInitialFocusBeenSet && nodePopulatedSet.cardinality() == 9) {
+      // Once the ExploreByTouchHelper knows about all of our virtual views,
+      // but not before, ask that the accessibility focus be moved from
+      // it's initial position on HOST_ID to the one we want to start with.
+      hasInitialFocusBeenSet = true;
+      handler.post(
+          new Runnable() {
+            @Override
+            public void run() {
+              sendEventForVirtualView(
+                  previousFocusedViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
+            }
+          });
+    }
+  }
+
+  @Override
+  protected boolean onPerformActionForVirtualView(int virtualViewId, int action, Bundle arguments) {
+    return false;
+  }
+
+  /** A simple equivilent to Assert.assertEquals so we don't depend on junit */
+  private static void assertEquals(int expected, int actual) {
+    if (expected != actual) {
+      throw new RuntimeException("Expected " + expected + " actual " + actual);
+    }
+  }
+
+  /**
+   * Unit test for getPatchNumber().
+   *
+   * <p>As of this writing, the Java portion of the Cobalt build has no unit test mechanism.
+   *
+   * <p>To run this test, simply call it from application start and start the application.
+   *
+   * <p>TODO: Move this to a real unit test location when one exists.
+   */
+  private static void testGetPatchNumber() {
+    Log.i(TAG, "+testGetPatchNumber");
+
+    assertEquals(1, getPatchNumber(5, 1));
+    assertEquals(2, getPatchNumber(5, 2));
+    assertEquals(3, getPatchNumber(5, 3));
+    assertEquals(4, getPatchNumber(5, 4));
+    assertEquals(5, getPatchNumber(5, 5));
+    assertEquals(6, getPatchNumber(5, 6));
+    assertEquals(7, getPatchNumber(5, 7));
+    assertEquals(8, getPatchNumber(5, 8));
+    assertEquals(9, getPatchNumber(5, 9));
+
+    for (int i = 1; i <= 9; i++) {
+      assertEquals(5, getPatchNumber(i, i));
+    }
+
+    assertEquals(5, getPatchNumber(1, 1));
+    assertEquals(6, getPatchNumber(1, 2));
+    assertEquals(4, getPatchNumber(1, 3));
+    assertEquals(8, getPatchNumber(1, 4));
+    assertEquals(9, getPatchNumber(1, 5));
+    assertEquals(7, getPatchNumber(1, 6));
+    assertEquals(2, getPatchNumber(1, 7));
+    assertEquals(3, getPatchNumber(1, 8));
+    assertEquals(1, getPatchNumber(1, 9));
+
+    assertEquals(9, getPatchNumber(9, 1));
+    assertEquals(7, getPatchNumber(9, 2));
+    assertEquals(8, getPatchNumber(9, 3));
+    assertEquals(3, getPatchNumber(9, 4));
+    assertEquals(1, getPatchNumber(9, 5));
+    assertEquals(2, getPatchNumber(9, 6));
+    assertEquals(6, getPatchNumber(9, 7));
+    assertEquals(4, getPatchNumber(9, 8));
+    assertEquals(5, getPatchNumber(9, 9));
+    Log.i(TAG, "-testGetPatchNumber");
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/CobaltActivity.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/CobaltActivity.java
new file mode 100644
index 0000000..2c68ab9
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/CobaltActivity.java
@@ -0,0 +1,261 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.coat;
+
+import static dev.cobalt.util.Log.TAG;
+
+import android.app.NativeActivity;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.media.AudioManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.ViewGroup.LayoutParams;
+import android.view.ViewTreeObserver;
+import android.widget.FrameLayout;
+import dev.cobalt.media.VideoSurfaceView;
+import dev.cobalt.util.Log;
+import dev.cobalt.util.UsedByNative;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/** Native activity that has the required JNI methods called by the Starboard implementation. */
+public abstract class CobaltActivity extends NativeActivity {
+
+  // A place to put args while debugging so they're used even when starting from the launcher.
+  // This should always be empty in submitted code.
+  private static final String[] DEBUG_ARGS = {};
+
+  private static final String URL_ARG = "--url=";
+  private static final java.lang.String META_DATA_APP_URL = "cobalt.APP_URL";
+
+  private static final String SPLASH_URL_ARG = "--fallback_splash_screen_url=";
+  private static final java.lang.String META_DATA_SPLASH_URL = "cobalt.SPLASH_URL";
+
+  private static final String FORCE_MIGRATION_FOR_STORAGE_PARTITIONING =
+      "--force_migration_for_storage_partitioning";
+  private static final String META_FORCE_MIGRATION_FOR_STORAGE_PARTITIONING =
+      "cobalt.force_migration_for_storage_partitioning";
+
+  @SuppressWarnings("unused")
+  private CobaltA11yHelper a11yHelper;
+
+  private VideoSurfaceView videoSurfaceView;
+
+  private ViewTreeObserver.OnGlobalLayoutListener videoSurfaceLayoutListener;
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    // To ensure that volume controls adjust the correct stream, make this call
+    // early in the app's lifecycle. This connects the volume controls to
+    // STREAM_MUSIC whenever the target activity or fragment is visible.
+    setVolumeControlStream(AudioManager.STREAM_MUSIC);
+
+    String startDeepLink = getIntentUrlAsString(getIntent());
+    if (getStarboardBridge() == null) {
+      // Cold start - Instantiate the singleton StarboardBridge.
+      StarboardBridge starboardBridge = createStarboardBridge(getArgs(), startDeepLink);
+      ((StarboardBridge.HostApplication) getApplication()).setStarboardBridge(starboardBridge);
+    } else {
+      // Warm start - Pass the deep link to the running Starboard app.
+      getStarboardBridge().handleDeepLink(startDeepLink);
+    }
+
+    // super.onCreate() will cause an APP_CMD_START in native code,
+    // so make sure to initialize any state beforehand that might be touched by
+    // native code invocations.
+    super.onCreate(savedInstanceState);
+
+    videoSurfaceView = new VideoSurfaceView(this);
+    a11yHelper = new CobaltA11yHelper(videoSurfaceView);
+    addContentView(
+        videoSurfaceView, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
+
+    videoSurfaceLayoutListener =
+        new ViewTreeObserver.OnGlobalLayoutListener() {
+          @Override
+          public void onGlobalLayout() {
+            VideoSurfaceView.nativeOnGlobalLayout();
+          }
+        };
+    ViewTreeObserver observer = getWindow().getDecorView().getViewTreeObserver();
+    observer.addOnGlobalLayoutListener(videoSurfaceLayoutListener);
+  }
+
+  /**
+   * Instantiates the StarboardBridge. Apps not supporting sign-in should inject an instance of
+   * NoopUserAuthorizer. Apps may subclass StarboardBridge if they need to override anything.
+   */
+  protected abstract StarboardBridge createStarboardBridge(String[] args, String startDeepLink);
+
+  @UsedByNative
+  protected StarboardBridge getStarboardBridge() {
+    return ((StarboardBridge.HostApplication) getApplication()).getStarboardBridge();
+  }
+
+  @Override
+  protected void onStart() {
+    getStarboardBridge().onActivityStart(this);
+    super.onStart();
+  }
+
+  @Override
+  protected void onStop() {
+    getStarboardBridge().onActivityStop(this);
+    super.onStop();
+  }
+
+  @Override
+  protected void onDestroy() {
+    ViewTreeObserver observer = getWindow().getDecorView().getViewTreeObserver();
+    observer.removeOnGlobalLayoutListener(videoSurfaceLayoutListener);
+
+    super.onDestroy();
+    getStarboardBridge().onActivityDestroy(this);
+  }
+
+  @Override
+  public boolean onSearchRequested() {
+    return getStarboardBridge().onSearchRequested();
+  }
+
+  /** Returns true if the argument list contains an arg starting with argName. */
+  private static boolean hasArg(List<String> args, String argName) {
+    for (String arg : args) {
+      if (arg.startsWith(argName)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Get argv/argc style args, if any from intent extras. Returns empty array if there are none
+   *
+   * <p>To use, invoke application via, eg, adb shell am start --esa args arg1,arg2 \
+   * dev.cobalt.coat/dev.cobalt.app.MainActivity
+   */
+  protected String[] getArgs() {
+    Bundle extras = getIntent().getExtras();
+    CharSequence[] argsExtra =
+        (extras == null || isReleaseBuild()) ? null : extras.getCharSequenceArray("args");
+
+    List<String> args = new ArrayList<>(Arrays.asList(DEBUG_ARGS));
+    if (argsExtra != null) {
+      for (int i = 0; i < argsExtra.length; i++) {
+        args.add(argsExtra[i].toString());
+      }
+    }
+
+    // If the URL arg isn't specified, get it from AndroidManifest.xml.
+    boolean hasUrlArg = hasArg(args, URL_ARG);
+    // If the splash screen url arg isn't specified, get it from AndroidManifest.xml.
+    boolean hasSplashUrlArg = hasArg(args, SPLASH_URL_ARG);
+    if (!hasUrlArg || !hasSplashUrlArg) {
+      try {
+        ActivityInfo ai =
+            getPackageManager()
+                .getActivityInfo(getIntent().getComponent(), PackageManager.GET_META_DATA);
+        if (ai.metaData != null) {
+          if (!hasUrlArg) {
+            String url = ai.metaData.getString(META_DATA_APP_URL);
+            if (url != null) {
+              args.add(URL_ARG + url);
+            }
+          }
+          if (!hasSplashUrlArg) {
+            String splashUrl = ai.metaData.getString(META_DATA_SPLASH_URL);
+            if (splashUrl != null) {
+              args.add(SPLASH_URL_ARG + splashUrl);
+            }
+          }
+          if (ai.metaData.getBoolean(META_FORCE_MIGRATION_FOR_STORAGE_PARTITIONING)) {
+            args.add(FORCE_MIGRATION_FOR_STORAGE_PARTITIONING);
+          }
+        }
+      } catch (NameNotFoundException e) {
+        throw new RuntimeException("Error getting activity info", e);
+      }
+    }
+
+    return args.toArray(new String[0]);
+  }
+
+  protected boolean isReleaseBuild() {
+    return StarboardBridge.isReleaseBuild();
+  }
+
+  @Override
+  protected void onNewIntent(Intent intent) {
+    getStarboardBridge().handleDeepLink(getIntentUrlAsString(intent));
+  }
+
+  /**
+   * Returns the URL from an Intent as a string. This may be overridden for additional processing.
+   */
+  protected String getIntentUrlAsString(Intent intent) {
+    Uri intentUri = intent.getData();
+    return (intentUri == null) ? null : intentUri.toString();
+  }
+
+  @Override
+  protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+    super.onActivityResult(requestCode, resultCode, data);
+    getStarboardBridge().onActivityResult(requestCode, resultCode, data);
+  }
+
+  @Override
+  public void onRequestPermissionsResult(
+      int requestCode, String[] permissions, int[] grantResults) {
+    getStarboardBridge().onRequestPermissionsResult(requestCode, permissions, grantResults);
+  }
+
+  public void setVideoSurfaceBounds(final int x, final int y, final int width, final int height) {
+    if (!videoSurfaceView.updateVideoBounds(x, y, width, height)) {
+      return;
+    }
+
+    VideoSurfaceView.nativeOnLayoutNeeded();
+    runOnUiThread(
+        new Runnable() {
+          @Override
+          public void run() {
+            VideoSurfaceView.nativeOnLayoutScheduled();
+            LayoutParams layoutParams = videoSurfaceView.getLayoutParams();
+            // Since videoSurfaceView is added directly to the Activity's content view, which is a
+            // FrameLayout, we expect its layout params to become FrameLayout.LayoutParams.
+            if (layoutParams instanceof FrameLayout.LayoutParams) {
+              ((FrameLayout.LayoutParams) layoutParams).setMargins(x, y, x + width, y + height);
+            } else {
+              Log.w(
+                  TAG,
+                  "Unexpected video surface layout params class "
+                      + layoutParams.getClass().getName());
+            }
+            layoutParams.width = width;
+            layoutParams.height = height;
+            // Even though as a NativeActivity we're not using the Android UI framework, by setting
+            // the  layout params it will force a layout to be requested. That will cause the
+            // SurfaceView to position its underlying Surface to match the screen coordinates of
+            // where the view would be in a UI layout and to set the surface transform matrix to
+            // match the view's size.
+            videoSurfaceView.setLayoutParams(layoutParams);
+          }
+        });
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/CobaltHttpHelper.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/CobaltHttpHelper.java
new file mode 100644
index 0000000..56a2ffd
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/CobaltHttpHelper.java
@@ -0,0 +1,103 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.coat;
+
+import static dev.cobalt.util.Log.TAG;
+
+import dev.cobalt.util.Log;
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+
+/** Helper class that implements an HTTP POST function used by DRM one-time provisioning. */
+public class CobaltHttpHelper {
+  private static final int RETRY_SLEEP_MILLIS = 250;
+  private static final int MAX_ATTEMPTS = 3;
+
+  /** Exception representing a transient HTTP failure (eg, 500). */
+  private static class TransientFailure extends Exception {}
+
+  private static void sleepBeforeRetry() {
+    try {
+      Thread.sleep(RETRY_SLEEP_MILLIS);
+    } catch (InterruptedException ex) {
+      // should never happen
+    }
+  }
+
+  /**
+   * Performs an HTTP POST, sending postData to url and returning the response contents on 200.
+   *
+   * <p>Note that this function retries temporary failures (network, HTTP 500) a few times.
+   *
+   * <p>Note also that this sets a few DRM-specific headers.
+   *
+   * @return response contents on success, null for permanent failure or exception.
+   */
+  public byte[] performDrmHttpPost(String url) {
+    for (int attempts = 0; attempts < MAX_ATTEMPTS; attempts++) {
+      try {
+        return internalPerformHttpPost(url);
+      } catch (IOException ex) {
+        Log.w(TAG, "performHttpPost IOException: ", ex);
+        // continue below
+      } catch (TransientFailure ex) {
+        // continue below
+      } catch (Throwable tr) {
+        // All other exceptions are caught because the caller is expected
+        // to be a JNI function, where exception handling is inconvenient.
+        Log.e(TAG, "performHttpPost exception: ", tr);
+        return null;
+      }
+      sleepBeforeRetry();
+    }
+    Log.w(TAG, "performHttpPost: Max attempts attempted");
+    return null;
+  }
+
+  private byte[] internalPerformHttpPost(String url) throws IOException, TransientFailure {
+    HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
+    try {
+      conn.setRequestMethod("POST");
+      conn.setDoOutput(false);
+      conn.setDoInput(true);
+
+      int statusCode = conn.getResponseCode();
+
+      if (statusCode >= 500 && statusCode <= 599) {
+        // We retry on 5xx failures.
+        Log.i(TAG, "performHttpPost transient failure: " + conn.getResponseMessage());
+        throw new TransientFailure();
+      }
+
+      if (statusCode != 200) {
+        Log.i(TAG, "performHttpPost permanent failure: " + conn.getResponseMessage());
+        return null;
+      }
+
+      ByteArrayOutputStream output = new ByteArrayOutputStream();
+      BufferedInputStream input = new BufferedInputStream(conn.getInputStream());
+      for (int b = input.read(); b != -1; b = input.read()) {
+        output.write(b);
+      }
+
+      return output.toByteArray();
+    } finally {
+      conn.disconnect();
+    }
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/CobaltTextToSpeechHelper.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/CobaltTextToSpeechHelper.java
new file mode 100644
index 0000000..f7809b0
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/CobaltTextToSpeechHelper.java
@@ -0,0 +1,206 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.coat;
+
+import static dev.cobalt.util.Log.TAG;
+
+import android.accessibilityservice.AccessibilityServiceInfo;
+import android.content.Context;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.speech.tts.TextToSpeech;
+import android.view.accessibility.AccessibilityManager;
+import dev.cobalt.util.Log;
+import dev.cobalt.util.UsedByNative;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Helper class to implement the SbSpeechSynthesis* Starboard API for Audio accessibility.
+ *
+ * <p>This class is intended to be a singleton in the system. It creates a single static Handler
+ * thread in lieu of other synchronization options.
+ */
+class CobaltTextToSpeechHelper
+    implements TextToSpeech.OnInitListener,
+        AccessibilityManager.AccessibilityStateChangeListener,
+        AccessibilityManager.TouchExplorationStateChangeListener {
+  private final Context context;
+  private final Runnable stopRequester;
+  private final HandlerThread thread;
+  private final Handler handler;
+
+  // The TTS engine should be used only on the background thread.
+  private TextToSpeech ttsEngine;
+
+  private boolean wasScreenReaderEnabled;
+
+  private enum State {
+    PENDING,
+    INITIALIZED,
+    FAILED
+  }
+
+  // These are only accessed inside the Handler Thread
+  private State state = State.PENDING;
+  private long nextUtteranceId;
+  private final List<String> pendingUtterances = new ArrayList<>();
+
+  CobaltTextToSpeechHelper(Context context, Runnable stopRequester) {
+    this.context = context;
+    this.stopRequester = stopRequester;
+
+    thread = new HandlerThread("CobaltTextToSpeechHelper");
+    thread.start();
+    handler = new Handler(thread.getLooper());
+
+    AccessibilityManager accessibilityManager =
+        (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
+    wasScreenReaderEnabled = isScreenReaderEnabled();
+    accessibilityManager.addAccessibilityStateChangeListener(this);
+    accessibilityManager.addTouchExplorationStateChangeListener(this);
+  }
+
+  public void shutdown() {
+
+    handler.post(
+        new Runnable() {
+          @Override
+          public void run() {
+            if (ttsEngine != null) {
+              ttsEngine.shutdown();
+            }
+          }
+        });
+    thread.quitSafely();
+
+    AccessibilityManager accessibilityManager =
+        (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
+    accessibilityManager.removeAccessibilityStateChangeListener(this);
+    accessibilityManager.removeTouchExplorationStateChangeListener(this);
+  }
+
+  /** Returns whether a screen reader is currently enabled */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public boolean isScreenReaderEnabled() {
+    AccessibilityManager am =
+        (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
+    final List<AccessibilityServiceInfo> screenReaders =
+        am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_SPOKEN);
+    return !screenReaders.isEmpty();
+  }
+
+  /** Implementation of TextToSpeech.OnInitListener */
+  @Override
+  public void onInit(final int status) {
+    handler.post(
+        new Runnable() {
+          @Override
+          public void run() {
+            if (status != TextToSpeech.SUCCESS) {
+              Log.e(TAG, "TextToSpeech.onInit failure: " + status);
+              state = State.FAILED;
+              return;
+            }
+            state = State.INITIALIZED;
+            for (String utterance : pendingUtterances) {
+              speak(utterance);
+            }
+            pendingUtterances.clear();
+          }
+        });
+  }
+
+  /**
+   * Speaks the given text, enqueuing it if something is already speaking. Java-layer implementation
+   * of Starboard's SbSpeechSynthesisSpeak.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  void speak(final String text) {
+
+    handler.post(
+        new Runnable() {
+          @Override
+          public void run() {
+
+            if (ttsEngine == null) {
+              ttsEngine = new TextToSpeech(context, CobaltTextToSpeechHelper.this);
+            }
+
+            switch (state) {
+              case PENDING:
+                pendingUtterances.add(text);
+                break;
+              case INITIALIZED:
+                int success =
+                    ttsEngine.speak(
+                        text, TextToSpeech.QUEUE_ADD, null, Long.toString(nextUtteranceId++));
+
+                if (success != TextToSpeech.SUCCESS) {
+                  Log.e(TAG, "TextToSpeech.speak error: " + success);
+                  return;
+                }
+                break;
+              case FAILED:
+                break;
+            }
+          }
+        });
+  }
+
+  /** Cancels all speaking. Java-layer implementation of Starboard's SbSpeechSynthesisCancel. */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  void cancel() {
+    handler.post(
+        new Runnable() {
+          @Override
+          public void run() {
+            if (ttsEngine != null) {
+              ttsEngine.stop();
+            }
+            pendingUtterances.clear();
+          }
+        });
+  }
+
+  @Override
+  public void onAccessibilityStateChanged(boolean enabled) {
+    // Note that this callback isn't perfect since it only tells us if accessibility was entirely
+    // enabled/disabled, but it's better than nothing. For example, it won't be called if the screen
+    // reader is enabled/disabled while text magnification remains enabled.
+    finishIfScreenReaderChanged();
+  }
+
+  @Override
+  public void onTouchExplorationStateChanged(boolean enabled) {
+    // We also listen for talkback changes because it's the standard (but not only) screen reader,
+    // and we can get a better signal than just listening for accessibility being enabled/disabled.
+    finishIfScreenReaderChanged();
+  }
+
+  /**
+   * Quit the app if screen reader settings changed so we respect the new setting the next time the
+   * app is run. This should only happen while stopped in the background since the user has to leave
+   * the app to change the setting.
+   */
+  private void finishIfScreenReaderChanged() {
+    if (wasScreenReaderEnabled != isScreenReaderEnabled()) {
+      stopRequester.run();
+    }
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/ErrorDialog.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/ErrorDialog.java
new file mode 100644
index 0000000..35bc1f0
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/ErrorDialog.java
@@ -0,0 +1,125 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.coat;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+/**
+ * A fullscreen dialog to show an error, with up to 3 buttons. This has a look similar to the
+ * Android TV leanback ErrorFragment. As a dialog, it creates its own window so it can be shown on
+ * top of our NativeActivity, unlike a fragment.
+ */
+class ErrorDialog extends Dialog {
+
+  public static final int MAX_BUTTONS = 3;
+
+  private final Params params;
+
+  private static class Params {
+    private int messageId;
+    private int numButtons = 0;
+    private int[] buttonIds = new int[MAX_BUTTONS];
+    private int[] buttonLabelIds = new int[MAX_BUTTONS];
+    private OnClickListener buttonClickListener;
+    private OnDismissListener dismissListener;
+  }
+
+  public static class Builder {
+
+    private Context context;
+    private Params params = new Params();
+
+    public Builder(Context context) {
+      this.context = context;
+    }
+
+    public Builder setMessage(int messageId) {
+      params.messageId = messageId;
+      return this;
+    }
+
+    public Builder addButton(int id, int labelId) {
+      if (params.numButtons >= MAX_BUTTONS) {
+        throw new IllegalArgumentException("Too many buttons");
+      }
+      params.buttonIds[params.numButtons] = id;
+      params.buttonLabelIds[params.numButtons] = labelId;
+      params.numButtons++;
+      return this;
+    }
+
+    public Builder setButtonClickListener(OnClickListener buttonClickListener) {
+      params.buttonClickListener = buttonClickListener;
+      return this;
+    }
+
+    public Builder setOnDismissListener(OnDismissListener dismissListener) {
+      params.dismissListener = dismissListener;
+      return this;
+    }
+
+    public ErrorDialog create() {
+      return new ErrorDialog(context, params);
+    }
+  }
+
+  private ErrorDialog(Context context, Params params) {
+    super(context);
+    this.params = params;
+  }
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+    setContentView(R.layout.coat_error_dialog);
+
+    ImageView imageView = (ImageView) findViewById(R.id.image);
+    Drawable drawable = getContext().getResources().getDrawable(
+        R.drawable.lb_ic_sad_cloud, getContext().getTheme());
+    imageView.setImageDrawable(drawable);
+
+    TextView messageView = (TextView) findViewById(R.id.message);
+    messageView.setText(params.messageId);
+
+    Button button = (Button) findViewById(R.id.button_1);
+    ViewGroup container = (ViewGroup) button.getParent();
+    int buttonIndex = container.indexOfChild(button);
+
+    for (int i = 0; i < params.numButtons; i++) {
+      button = (Button) container.getChildAt(buttonIndex + i);
+      button.setText(params.buttonLabelIds[i]);
+      button.setVisibility(View.VISIBLE);
+
+      final int buttonId = params.buttonIds[i];
+      button.setOnClickListener(new View.OnClickListener() {
+        @Override
+        public void onClick(View v) {
+          params.buttonClickListener.onClick(ErrorDialog.this, buttonId);
+        }
+      });
+    }
+
+    setOnDismissListener(params.dismissListener);
+  }
+
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/PlatformError.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/PlatformError.java
new file mode 100644
index 0000000..4aa82e7
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/PlatformError.java
@@ -0,0 +1,153 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.coat;
+
+import static dev.cobalt.util.Log.TAG;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.Settings;
+import android.support.annotation.IntDef;
+import dev.cobalt.util.Holder;
+import dev.cobalt.util.Log;
+import dev.cobalt.util.UsedByNative;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Shows an ErrorDialog to inform the user of a Starboard platform error.
+ */
+public class PlatformError
+    implements DialogInterface.OnClickListener, DialogInterface.OnDismissListener {
+
+  @Retention(RetentionPolicy.SOURCE)
+  @IntDef({CONNECTION_ERROR})
+  @interface ErrorType {}
+  // This must be kept in sync with starboard/android/shared/system_platform_error.cc
+  public static final int CONNECTION_ERROR = 0;
+
+  @Retention(RetentionPolicy.SOURCE)
+  @IntDef({CANCELLED, NEGATIVE, POSITIVE})
+  @interface Response {}
+  public static final int NEGATIVE = -1;
+  public static final int CANCELLED = 0;
+  public static final int POSITIVE = 1;
+
+  // Button IDs for CONNECTION_ERROR
+  private static final int RETRY_BUTTON = 1;
+  private static final int NETWORK_SETTINGS_BUTTON = 2;
+
+  private final Holder<Activity> activityHolder;
+  private final @ErrorType int errorType;
+  private final long data;
+  private final Handler uiThreadHandler;
+
+  private Dialog dialog;
+  private int response;
+
+  public PlatformError(Holder<Activity> activityHolder, @ErrorType int errorType, long data) {
+    this.activityHolder = activityHolder;
+    this.errorType = errorType;
+    this.data = data;
+    uiThreadHandler = new Handler(Looper.getMainLooper());
+    response = CANCELLED;
+  }
+
+  /** Display the error. */
+  public void raise() {
+    uiThreadHandler.post(new Runnable() {
+      @Override
+      public void run() {
+        showDialogOnUiThread();
+      }
+    });
+  }
+
+  private void showDialogOnUiThread() {
+    Activity activity = activityHolder.get();
+    if (activity == null) {
+      onCleared(CANCELLED, data);
+      return;
+    }
+    ErrorDialog.Builder dialogBuilder = new ErrorDialog.Builder(activity);
+    switch (errorType) {
+      case CONNECTION_ERROR:
+        dialogBuilder
+            .setMessage(R.string.starboard_platform_connection_error)
+            .addButton(RETRY_BUTTON, R.string.starboard_platform_retry)
+            .addButton(NETWORK_SETTINGS_BUTTON, R.string.starboard_platform_network_settings);
+        break;
+      default:
+        Log.e(TAG, "Unknown platform error " + errorType);
+        return;
+    }
+    dialog = dialogBuilder
+        .setButtonClickListener(this)
+        .setOnDismissListener(this)
+        .create();
+    dialog.show();
+  }
+
+  /** Programmatically dismiss the error. */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public void clear() {
+    uiThreadHandler.post(
+        new Runnable() {
+          @Override
+          public void run() {
+            if (dialog != null) {
+              dialog.dismiss();
+            }
+          }
+        });
+  }
+
+  @Override
+  public void onClick(DialogInterface dialogInterface, int whichButton) {
+    if (errorType == CONNECTION_ERROR) {
+      switch (whichButton) {
+        case NETWORK_SETTINGS_BUTTON:
+          Activity activity = activityHolder.get();
+          if (activity != null) {
+            activity.startActivity(new Intent(Settings.ACTION_WIFI_SETTINGS));
+          }
+          break;
+        case RETRY_BUTTON:
+          response = POSITIVE;
+          dialog.dismiss();
+          break;
+        default: // fall out
+      }
+    }
+  }
+
+  @Override
+  public void onDismiss(DialogInterface dialogInterface) {
+    dialog = null;
+    onCleared(response, data);
+  }
+
+  /** Informs Starboard when the error is dismissed. */
+  protected void onCleared(@PlatformError.Response int response, long data) {
+    nativeOnCleared(response, data);
+  }
+
+  private native void nativeOnCleared(@PlatformError.Response int response, long data);
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/StarboardBridge.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/StarboardBridge.java
new file mode 100644
index 0000000..7fb75e5
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/StarboardBridge.java
@@ -0,0 +1,567 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.coat;
+
+import static android.content.Context.AUDIO_SERVICE;
+import static android.media.AudioManager.GET_DEVICES_INPUTS;
+import static dev.cobalt.util.Log.TAG;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.media.AudioDeviceInfo;
+import android.media.AudioManager;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.os.Build;
+import android.util.Pair;
+import android.util.Size;
+import android.view.WindowManager;
+import android.view.accessibility.AccessibilityManager;
+import android.view.accessibility.CaptioningManager;
+import dev.cobalt.account.UserAuthorizer;
+import dev.cobalt.feedback.FeedbackService;
+import dev.cobalt.media.AudioOutputManager;
+import dev.cobalt.media.CaptionSettings;
+import dev.cobalt.media.CobaltMediaSession;
+import dev.cobalt.media.MediaImage;
+import dev.cobalt.util.DisplayUtil;
+import dev.cobalt.util.Holder;
+import dev.cobalt.util.Log;
+import dev.cobalt.util.UsedByNative;
+import java.lang.reflect.Method;
+import java.net.InterfaceAddress;
+import java.net.NetworkInterface;
+import java.net.SocketException;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Locale;
+
+/** Implementation of the required JNI methods called by the Starboard C++ code. */
+public class StarboardBridge {
+
+  /** Interface to be implemented by the Android Application hosting the starboard app. */
+  public interface HostApplication {
+    void setStarboardBridge(StarboardBridge starboardBridge);
+
+    StarboardBridge getStarboardBridge();
+  }
+
+  private CobaltTextToSpeechHelper ttsHelper;
+  private UserAuthorizer userAuthorizer;
+  private FeedbackService feedbackService;
+  private AudioOutputManager audioOutputManager;
+  private CobaltMediaSession cobaltMediaSession;
+  private VoiceRecognizer voiceRecognizer;
+  private AudioPermissionRequester audioPermissionRequester;
+
+  static {
+    // Even though NativeActivity already loads our library from C++,
+    // we still have to load it from Java to make JNI calls into it.
+    System.loadLibrary("coat");
+  }
+
+  private final Context appContext;
+  private final Holder<Activity> activityHolder;
+  private final String[] args;
+  private final String startDeepLink;
+  private final Runnable stopRequester =
+      new Runnable() {
+        @Override
+        public void run() {
+          requestStop(0);
+        }
+      };
+
+  private volatile boolean starboardStopped = false;
+
+  public StarboardBridge(
+      Context appContext,
+      Holder<Activity> activityHolder,
+      UserAuthorizer userAuthorizer,
+      FeedbackService feedbackService,
+      String[] args,
+      String startDeepLink) {
+
+    // Make sure the JNI stack is properly initialized first as there is
+    // race condition as soon as any of the following objects creates a new thread.
+    nativeInitialize();
+
+    this.appContext = appContext;
+    this.activityHolder = activityHolder;
+    this.args = args;
+    this.startDeepLink = startDeepLink;
+    this.ttsHelper = new CobaltTextToSpeechHelper(appContext, stopRequester);
+    this.userAuthorizer = userAuthorizer;
+    this.feedbackService = feedbackService;
+    this.audioOutputManager = new AudioOutputManager(appContext);
+    this.cobaltMediaSession =
+        new CobaltMediaSession(appContext, activityHolder, audioOutputManager);
+    this.audioPermissionRequester = new AudioPermissionRequester(appContext, activityHolder);
+    this.voiceRecognizer =
+        new VoiceRecognizer(appContext, activityHolder, audioPermissionRequester);
+  }
+
+  private native boolean nativeInitialize();
+
+  protected void onActivityStart(Activity activity) {
+    activityHolder.set(activity);
+  }
+
+  protected void onActivityStop(Activity activity) {
+    if (activityHolder.get() == activity) {
+      activityHolder.set(null);
+    }
+  }
+
+  protected void onActivityDestroy(Activity activity) {
+    if (starboardStopped) {
+      // We can't restart the starboard app, so kill the process for a clean start next time.
+      Log.i(TAG, "Activity destroyed after shutdown; killing app.");
+      System.exit(0);
+    } else {
+      Log.i(TAG, "Activity destroyed without shutdown; app suspended in background.");
+    }
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  void beforeStartOrResume() {
+    Log.i(TAG, "Prepare to resume");
+    // Bring our platform services to life before resuming so that they're ready to deal with
+    // whatever the web app wants to do with them as part of its start/resume logic.
+    cobaltMediaSession.resume();
+    feedbackService.connect();
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  void beforeSuspend() {
+    Log.i(TAG, "Prepare to suspend");
+    // We want the MediaSession to be deactivated immediately before suspending so that by the time
+    // the launcher is visible our "Now Playing" card is already gone. Then Cobalt and the web app
+    // can take their time suspending after that.
+    cobaltMediaSession.suspend();
+    feedbackService.disconnect();
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  void afterStopped() {
+    starboardStopped = true;
+    ttsHelper.shutdown();
+    userAuthorizer.shutdown();
+    Activity activity = activityHolder.get();
+    if (activity != null) {
+      // Wait until the activity is destroyed to exit.
+      Log.i(TAG, "Shutdown in foreground; finishing Activity and removing task.");
+      activity.finishAndRemoveTask();
+    } else {
+      // We can't restart the starboard app, so kill the process for a clean start next time.
+      Log.i(TAG, "Shutdown in background; killing app without removing task.");
+      System.exit(0);
+    }
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public void requestStop(int errorLevel) {
+    if (!starboardStopped) {
+      nativeStopApp(errorLevel);
+    }
+  }
+
+  private native void nativeStopApp(int errorLevel);
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public void requestSuspend() {
+    Activity activity = activityHolder.get();
+    if (activity != null) {
+      activity.finish();
+    }
+  }
+
+  public boolean onSearchRequested() {
+    return nativeOnSearchRequested();
+  }
+
+  private native boolean nativeOnSearchRequested();
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public Context getApplicationContext() {
+    return appContext;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  PlatformError raisePlatformError(@PlatformError.ErrorType int errorType, long data) {
+    PlatformError error = new PlatformError(activityHolder, errorType, data);
+    error.raise();
+    return error;
+  }
+
+  /** Returns true if the native code is compiled for release (i.e. 'gold' build). */
+  public static boolean isReleaseBuild() {
+    return nativeIsReleaseBuild();
+  }
+
+  private static native boolean nativeIsReleaseBuild();
+
+  protected Context getAppContext() {
+    return appContext;
+  }
+
+  protected Holder<Activity> getActivityHolder() {
+    return activityHolder;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  protected String[] getArgs() {
+    return args;
+  }
+
+  /** Returns the URL from the Intent that started the app. */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  protected String getStartDeepLink() {
+    return startDeepLink;
+  }
+
+  /** Sends an event to the web app to navigate to the given URL */
+  public void handleDeepLink(String url) {
+    nativeHandleDeepLink(url);
+  }
+
+  private native void nativeHandleDeepLink(String url);
+
+  /**
+   * Returns the absolute path to the directory where application specific files should be written.
+   * May be overridden for use cases that need to segregate storage.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  protected String getFilesAbsolutePath() {
+    return appContext.getFilesDir().getAbsolutePath();
+  }
+
+  /**
+   * Returns the absolute path to the application specific cache directory on the filesystem. May be
+   * overridden for use cases that need to segregate storage.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  protected String getCacheAbsolutePath() {
+    return appContext.getCacheDir().getAbsolutePath();
+  }
+
+  /**
+   * Returns non-loopback network interface address and its netmask, or null if none.
+   *
+   * <p>A Java function to help implement Starboard's SbSocketGetLocalInterfaceAddress.
+   *
+   * <p>Required for platforms older than 24. Since 24, bionic includes getifaddrs() which can be
+   * used by the C layer directly.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  Pair<byte[], byte[]> getLocalInterfaceAddressAndNetask(boolean wantIPv6) {
+    try {
+      Enumeration<NetworkInterface> it = NetworkInterface.getNetworkInterfaces();
+
+      while (it.hasMoreElements()) {
+        NetworkInterface ni = it.nextElement();
+        if (ni.isLoopback()) {
+          continue;
+        }
+        if (!ni.isUp()) {
+          continue;
+        }
+        if (ni.isPointToPoint()) {
+          continue;
+        }
+
+        for (InterfaceAddress ia : ni.getInterfaceAddresses()) {
+          byte[] address = ia.getAddress().getAddress();
+          boolean isIPv6 = (address.length > 4);
+          if (isIPv6 == wantIPv6) {
+            // Convert the network prefix length to a network mask.
+            int prefix = ia.getNetworkPrefixLength();
+            byte[] netmask = new byte[address.length];
+            for (int i = 0; i < netmask.length; i++) {
+              if (prefix == 0) {
+                netmask[i] = 0;
+              } else if (prefix >= 8) {
+                netmask[i] = (byte) 0xFF;
+                prefix -= 8;
+              } else {
+                netmask[i] = (byte) (0xFF << (8 - prefix));
+                prefix = 0;
+              }
+            }
+            return new Pair<>(address, netmask);
+          }
+        }
+      }
+    } catch (SocketException ex) {
+      // TODO should we have a logging story that strips logs for production?
+      Log.w(TAG, "sbSocketGetLocalInterfaceAddress exception", ex);
+      return null;
+    }
+    return null;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  CobaltTextToSpeechHelper getTextToSpeechHelper() {
+    return ttsHelper;
+  }
+
+  /** @return A new CaptionSettings object with the current system caption settings. */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  CaptionSettings getCaptionSettings() {
+    CaptioningManager cm =
+        (CaptioningManager) appContext.getSystemService(Context.CAPTIONING_SERVICE);
+    return new CaptionSettings(cm);
+  }
+
+  /** Java-layer implementation of SbSystemGetLocaleId. */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  String systemGetLocaleId() {
+    return Locale.getDefault().toLanguageTag();
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  Size getDisplaySize() {
+    return DisplayUtil.getSystemDisplaySize(appContext);
+  }
+
+  /**
+   * Checks if there is no microphone connected to the system.
+   *
+   * @return true if no device is connected.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public boolean isMicrophoneDisconnected() {
+    if (Build.VERSION.SDK_INT >= 23) {
+      return !isMicrophoneConnectedV23();
+    } else {
+      // There is no way of checking for a connected microphone/device before API 23, so cannot
+      // guarantee that no microphone is connected.
+      return false;
+    }
+  }
+
+  @TargetApi(23)
+  private boolean isMicrophoneConnectedV23() {
+    // A check specifically for microphones is not available before API 28, so it is assumed that a
+    // connected input audio device is a microphone.
+    AudioManager audioManager = (AudioManager) appContext.getSystemService(AUDIO_SERVICE);
+    AudioDeviceInfo[] devices = audioManager.getDevices(GET_DEVICES_INPUTS);
+    return devices.length > 0;
+  }
+
+  /**
+   * Checks if the microphone is muted.
+   *
+   * @return true if the microphone mute is on.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public boolean isMicrophoneMute() {
+    AudioManager audioManager = (AudioManager) appContext.getSystemService(AUDIO_SERVICE);
+    return audioManager.isMicrophoneMute();
+  }
+
+  /** @return true if we have an active network connection and it's on a wireless network. */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  boolean isCurrentNetworkWireless() {
+    ConnectivityManager connMgr =
+        (ConnectivityManager) appContext.getSystemService(Context.CONNECTIVITY_SERVICE);
+
+    NetworkInfo activeInfo = connMgr.getActiveNetworkInfo();
+
+    if (activeInfo == null) {
+      return false;
+    }
+
+    switch (activeInfo.getType()) {
+      case ConnectivityManager.TYPE_ETHERNET:
+        return false;
+      default:
+        // Consider anything that's not definitely wired to be wireless.
+        // For example, TYPE_VPN is ambiguous, but it's highly likely to be
+        // over wifi.
+        return true;
+    }
+  }
+
+  /**
+   * @return true if the user has enabled accessibility high contrast text in the operating system.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  boolean isAccessibilityHighContrastTextEnabled() {
+    AccessibilityManager am =
+        (AccessibilityManager) appContext.getSystemService(Context.ACCESSIBILITY_SERVICE);
+
+    try {
+      Method m = AccessibilityManager.class.getDeclaredMethod("isHighTextContrastEnabled");
+
+      return m.invoke(am) == Boolean.TRUE;
+    } catch (ReflectiveOperationException ex) {
+      return false;
+    }
+  }
+
+  /** Returns Java layer implementation for AndroidUserAuthorizer */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  UserAuthorizer getUserAuthorizer() {
+    return userAuthorizer;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  void sendFeedback(
+      HashMap<String, String> productSpecificData, String categoryTag, byte[] screenshotData) {
+    // Convert the screenshot byte array into a Bitmap.
+    Bitmap screenshotBitmap = null;
+    if ((screenshotData != null) && (screenshotData.length > 0)) {
+      screenshotBitmap = BitmapFactory.decodeByteArray(screenshotData, 0, screenshotData.length);
+      if (screenshotBitmap == null) {
+        Log.e(TAG, "Unable to decode a screenshot from the data.");
+      }
+    }
+    feedbackService.sendFeedback(productSpecificData, categoryTag, screenshotBitmap);
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  void updateMediaSession(
+      int playbackState,
+      long actions,
+      long positionMs,
+      float speed,
+      String title,
+      String artist,
+      String album,
+      MediaImage[] artwork) {
+    cobaltMediaSession.updateMediaSession(
+        playbackState, actions, positionMs, speed, title, artist, album, artwork);
+  }
+
+  /** Returns string for kSbSystemPropertyUserAgentAuxField */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  String getUserAgentAuxField() {
+    StringBuilder sb = new StringBuilder();
+
+    String packageName = appContext.getApplicationInfo().packageName;
+    sb.append(packageName);
+    sb.append('/');
+
+    try {
+      sb.append(appContext.getPackageManager().getPackageInfo(packageName, 0).versionName);
+    } catch (PackageManager.NameNotFoundException ex) {
+      // Should never happen
+      Log.e(TAG, "Can't find our own package", ex);
+    }
+
+    return sb.toString();
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  AudioOutputManager getAudioOutputManager() {
+    return audioOutputManager;
+  }
+
+  /** Returns Java layer implementation for AndroidVoiceRecognizer */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  VoiceRecognizer getVoiceRecognizer() {
+    return voiceRecognizer;
+  }
+
+  /** Returns Java layer implementation for AudioPermissionRequester */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  AudioPermissionRequester getAudioPermissionRequester() {
+    return audioPermissionRequester;
+  }
+
+  void onActivityResult(int requestCode, int resultCode, Intent data) {
+    userAuthorizer.onActivityResult(requestCode, resultCode, data);
+  }
+
+  void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+    userAuthorizer.onRequestPermissionsResult(requestCode, permissions, grantResults);
+    audioPermissionRequester.onRequestPermissionsResult(requestCode, permissions, grantResults);
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public void setVideoSurfaceBounds(final int x, final int y, final int width, final int height) {
+    Activity activity = activityHolder.get();
+    if (activity instanceof CobaltActivity) {
+      ((CobaltActivity) activity).setVideoSurfaceBounds(x, y, width, height);
+    }
+  }
+
+  /**
+   * Check if hdrType is supported by the current default display. See
+   * https://developer.android.com/reference/android/view/Display.HdrCapabilities.html for valid
+   * values.
+   */
+  @TargetApi(24)
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public boolean isHdrTypeSupported(int hdrType) {
+    if (android.os.Build.VERSION.SDK_INT < 24) {
+      return false;
+    }
+
+    Activity activity = activityHolder.get();
+    if (activity == null) {
+      return false;
+    }
+
+    WindowManager windowManager = activity.getWindowManager();
+    if (windowManager == null) {
+      return false;
+    }
+
+    int[] supportedHdrTypes =
+        windowManager.getDefaultDisplay().getHdrCapabilities().getSupportedHdrTypes();
+    for (int supportedType : supportedHdrTypes) {
+      if (supportedType == hdrType) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/VoiceRecognizer.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/VoiceRecognizer.java
new file mode 100644
index 0000000..d9aba31
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/coat/VoiceRecognizer.java
@@ -0,0 +1,193 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.coat;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.speech.RecognitionListener;
+import android.speech.RecognizerIntent;
+import android.speech.SpeechRecognizer;
+import dev.cobalt.util.Holder;
+import dev.cobalt.util.UsedByNative;
+import java.util.ArrayList;
+
+/**
+ * This class uses Android's SpeechRecognizer to perform speech recognition. Using Android's
+ * platform recognizer offers several benefits such as good quality and good local fallback when no
+ * data connection is available.
+ */
+public class VoiceRecognizer {
+  private final Context context;
+  private final Holder<Activity> activityHolder;
+  private final Handler mainHandler = new Handler(Looper.getMainLooper());
+  private final AudioPermissionRequester audioPermissionRequester;
+  private SpeechRecognizer speechRecognizer;
+
+  // Native pointer to C++ SbSpeechRecognizerImpl.
+  private long nativeSpeechRecognizerImpl;
+
+  // Remember if we are using continuous recognition.
+  private boolean continuous;
+  private boolean interimResults;
+  private int maxAlternatives;
+
+  // Internal class to handle events from Android's SpeechRecognizer and route
+  // them to native.
+  class Listener implements RecognitionListener {
+    @Override
+    public void onBeginningOfSpeech() {
+      nativeOnSpeechDetected(nativeSpeechRecognizerImpl, true);
+    }
+
+    @Override
+    public void onBufferReceived(byte[] buffer) {}
+
+    @Override
+    public void onEndOfSpeech() {
+      nativeOnSpeechDetected(nativeSpeechRecognizerImpl, false);
+    }
+
+    @Override
+    public void onError(int error) {
+      nativeOnError(nativeSpeechRecognizerImpl, error);
+      reset();
+    }
+
+    @Override
+    public void onEvent(int eventType, Bundle params) {}
+
+    @Override
+    public void onPartialResults(Bundle bundle) {
+      handleResults(bundle, false);
+    }
+
+    @Override
+    public void onReadyForSpeech(Bundle params) {}
+
+    @Override
+    public void onResults(Bundle bundle) {
+      handleResults(bundle, true);
+      reset();
+    }
+
+    @Override
+    public void onRmsChanged(float rmsdB) {}
+
+    private void handleResults(Bundle bundle, boolean isFinal) {
+      ArrayList<String> list = bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION);
+      String[] results = list.toArray(new String[list.size()]);
+      float[] scores = bundle.getFloatArray(SpeechRecognizer.CONFIDENCE_SCORES);
+      nativeOnResults(nativeSpeechRecognizerImpl, results, scores, isFinal);
+    }
+  };
+
+  public VoiceRecognizer(
+      Context context,
+      Holder<Activity> activityHolder,
+      AudioPermissionRequester audioPermissionRequester) {
+    this.context = context;
+    this.activityHolder = activityHolder;
+    this.audioPermissionRequester = audioPermissionRequester;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public void startRecognition(
+      boolean continuous,
+      boolean interimResults,
+      int maxAlternatives,
+      long nativeSpeechRecognizer) {
+    this.continuous = continuous;
+    this.interimResults = interimResults;
+    this.maxAlternatives = maxAlternatives;
+    this.nativeSpeechRecognizerImpl = nativeSpeechRecognizer;
+
+    if (this.audioPermissionRequester.requestRecordAudioPermission(
+        this.nativeSpeechRecognizerImpl)) {
+      startRecognitionInternal();
+    } else {
+      mainHandler.post(
+          new Runnable() {
+            @Override
+            public void run() {
+              nativeOnError(
+                  nativeSpeechRecognizerImpl, SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS);
+            }
+          });
+    }
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public void stopRecognition() {
+    Runnable runnable =
+        new Runnable() {
+          @Override
+          public void run() {
+            if (Looper.myLooper() != Looper.getMainLooper()) {
+              throw new RuntimeException("Must be called in main thread.");
+            }
+            if (speechRecognizer == null) {
+              return;
+            }
+            reset();
+          }
+        };
+    mainHandler.post(runnable);
+  }
+
+  private void startRecognitionInternal() {
+    Runnable runnable =
+        new Runnable() {
+          @Override
+          public void run() {
+            if (Looper.myLooper() != Looper.getMainLooper()) {
+              throw new RuntimeException("Must be called in main thread.");
+            }
+            speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context);
+            speechRecognizer.setRecognitionListener(new Listener());
+            Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
+            intent.putExtra(
+                RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
+            intent.putExtra("android.speech.extra.DICTATION_MODE", continuous);
+            intent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, interimResults);
+            intent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, maxAlternatives);
+            speechRecognizer.startListening(intent);
+          }
+        };
+    mainHandler.post(runnable);
+  }
+
+  private void reset() {
+    speechRecognizer.destroy();
+    speechRecognizer = null;
+
+    nativeSpeechRecognizerImpl = 0;
+    continuous = false;
+    interimResults = false;
+    maxAlternatives = 1;
+  }
+
+  private native void nativeOnSpeechDetected(long nativeSpeechRecognizerImpl, boolean detected);
+
+  private native void nativeOnError(long nativeSpeechRecognizerImpl, int error);
+
+  private native void nativeOnResults(
+      long nativeSpeechRecognizerImpl, String[] results, float[] confidences, boolean isFinal);
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/feedback/FeedbackService.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/feedback/FeedbackService.java
new file mode 100644
index 0000000..66a2547
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/feedback/FeedbackService.java
@@ -0,0 +1,36 @@
+// Copyright 2018 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.feedback;
+
+import android.graphics.Bitmap;
+import dev.cobalt.util.UsedByNative;
+import java.util.HashMap;
+
+/** Java side implementation for starboard::android::shared::cobalt::FeedbackService. */
+public interface FeedbackService {
+  /**
+   * Sends the given product specific data to the GMS Feedback Service.
+   *
+   * <p>Implementations must annotate this method with @UsedByNative so Proguard doesn't remove it.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  void sendFeedback(
+      HashMap<String, String> productSpecificData, String categoryTag, Bitmap screenshot);
+
+  void connect();
+
+  void disconnect();
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/feedback/NoopFeedbackService.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/feedback/NoopFeedbackService.java
new file mode 100644
index 0000000..0c59c84
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/feedback/NoopFeedbackService.java
@@ -0,0 +1,49 @@
+// Copyright 2018 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.feedback;
+
+import static dev.cobalt.util.Log.TAG;
+
+import android.graphics.Bitmap;
+import android.text.TextUtils;
+import dev.cobalt.util.Log;
+import dev.cobalt.util.UsedByNative;
+import java.util.HashMap;
+
+/** FeedbackService implementation that doesn't send any feedback. */
+public class NoopFeedbackService implements FeedbackService {
+  @Override
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public void sendFeedback(
+      HashMap<String, String> productSpecificData, String categoryTag, Bitmap screenshot) {
+    Log.i(TAG, "Feedback product specific data:");
+    for (String key : productSpecificData.keySet()) {
+      Log.i(TAG, key + ": " + productSpecificData.get(key));
+    }
+    if (screenshot != null) {
+      Log.i(TAG, "Screenshot dimensions: " + screenshot.getWidth() + "x" + screenshot.getHeight());
+    }
+    if (!TextUtils.isEmpty(categoryTag)) {
+      Log.i(TAG, "Category tag: " + categoryTag);
+    }
+  }
+
+  @Override
+  public void connect() {}
+
+  @Override
+  public void disconnect() {}
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/ArtworkLoader.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/ArtworkLoader.java
new file mode 100644
index 0000000..d76266f
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/ArtworkLoader.java
@@ -0,0 +1,167 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.media;
+
+import static dev.cobalt.media.Log.TAG;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.os.AsyncTask;
+import android.support.annotation.NonNull;
+import android.util.Pair;
+import android.util.Size;
+import dev.cobalt.util.Log;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+
+/** Loads MediaImage artwork, and caches one image. */
+public class ArtworkLoader {
+
+  /** Callback to receive the image loaded in the background by getOrLoadArtwork() */
+  public interface Callback {
+    void onArtworkLoaded(Bitmap bitmap);
+  }
+
+  @NonNull private volatile String requestedArtworkUrl = "";
+  @NonNull private volatile String currentArtworkUrl = "";
+  private volatile Bitmap currentArtwork = null;
+
+  private final Callback callback;
+  private final Size displaySize;
+
+  public ArtworkLoader(Callback callback, Size displaySize) {
+    this.callback = callback;
+    this.displaySize = displaySize;
+  }
+
+  /**
+   * Returns a cached image if available. If not cached, returns null and starts downloading it in
+   * the background, and then when ready the callback will be called with the image.
+   */
+  public synchronized Bitmap getOrLoadArtwork(MediaImage[] artwork) {
+    MediaImage image = getBestFitImage(artwork);
+    String url = (image == null) ? "" : image.src;
+
+    // Check if this artwork is already loaded or requested.
+    if (url.equals(currentArtworkUrl)) {
+      return currentArtwork;
+    } else if (url.equals(requestedArtworkUrl)) {
+      return null;
+    }
+
+    requestedArtworkUrl = url;
+    new DownloadArtworkTask().execute(url);
+    return null;
+  }
+
+  /**
+   * Returns the image that most closely matches the display size, or null if there are no images.
+   * We don't really know what size view the image may appear in (on the Now Playing card on Android
+   * TV launcher, or any other observer of the MediaSession), so we use display size as the largest
+   * useful size on any particular device.
+   */
+  private MediaImage getBestFitImage(MediaImage[] artwork) {
+    if (artwork == null || artwork.length == 0) {
+      return null;
+    }
+    MediaImage bestImage = artwork[0];
+    int minDiagonalSquared = Integer.MAX_VALUE;
+    for (MediaImage image : artwork) {
+      Size imageSize = parseImageSize(image);
+      int widthDelta = displaySize.getWidth() - imageSize.getWidth();
+      int heightDelta = displaySize.getHeight() - imageSize.getHeight();
+      int diagonalSquared = widthDelta * widthDelta + heightDelta * heightDelta;
+      if (diagonalSquared < minDiagonalSquared) {
+        bestImage = image;
+        minDiagonalSquared = diagonalSquared;
+      }
+    }
+    return bestImage;
+  }
+
+  private Size parseImageSize(MediaImage image) {
+    try {
+      String sizeStr = image.sizes.split("\\s+", -1)[0];
+      return Size.parseSize(sizeStr.toLowerCase());
+    } catch (NumberFormatException | NullPointerException e) {
+      return new Size(0, 0);
+    }
+  }
+
+  private synchronized void onDownloadFinished(Pair<String, Bitmap> urlBitmapPair) {
+    String url = urlBitmapPair.first;
+    Bitmap bitmap = urlBitmapPair.second;
+    if (url.equals(requestedArtworkUrl)) {
+      requestedArtworkUrl = "";
+      if (bitmap != null) {
+        currentArtworkUrl = url;
+        currentArtwork = bitmap;
+        callback.onArtworkLoaded(bitmap);
+      }
+    }
+  }
+
+  private class DownloadArtworkTask extends AsyncTask<String, Void, Pair<String, Bitmap>> {
+
+    @Override
+    protected Pair<String, Bitmap> doInBackground(String... params) {
+      String url = params[0];
+      Bitmap bitmap = null;
+      HttpURLConnection conn = null;
+      InputStream is = null;
+      try {
+        conn = (HttpURLConnection) new URL(url).openConnection();
+        is = conn.getInputStream();
+        bitmap = BitmapFactory.decodeStream(is);
+      } catch (IOException e) {
+        Log.e(TAG, "Could not download artwork", e);
+      } finally {
+        try {
+          if (conn != null) {
+            conn.disconnect();
+          }
+          if (is != null) {
+            is.close();
+          }
+        } catch (Exception e) {
+          Log.e(TAG, "Error closing connection for artwork", e);
+        }
+      }
+
+      // Crop to 16:9 as needed
+      if (bitmap != null) {
+        int height = bitmap.getWidth() * 9 / 16;
+        if (bitmap.getHeight() > height) {
+          int top = (bitmap.getHeight() - height) / 2;
+          bitmap = Bitmap.createBitmap(bitmap, 0, top, bitmap.getWidth(), height);
+        }
+      }
+
+      return Pair.create(url, bitmap);
+    }
+
+    @Override
+    protected void onPostExecute(Pair<String, Bitmap> urlBitmapPair) {
+      onDownloadFinished(urlBitmapPair);
+    }
+
+    @Override
+    protected void onCancelled(Pair<String, Bitmap> urlBitmapPair) {
+      onDownloadFinished(urlBitmapPair);
+    }
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/AudioOutputManager.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/AudioOutputManager.java
new file mode 100644
index 0000000..0a72bc8
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/AudioOutputManager.java
@@ -0,0 +1,108 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.media;
+
+import static dev.cobalt.media.Log.TAG;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.media.AudioDeviceInfo;
+import android.media.AudioManager;
+import android.os.Build;
+import dev.cobalt.util.Log;
+import dev.cobalt.util.UsedByNative;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Creates and destroys AudioTrackBridge and handles the volume change. */
+public class AudioOutputManager implements CobaltMediaSession.UpdateVolumeListener {
+  private List<AudioTrackBridge> audioTrackBridgeList;
+  private Context context;
+
+  public AudioOutputManager(Context context) {
+    this.context = context;
+    audioTrackBridgeList = new ArrayList<AudioTrackBridge>();
+  }
+
+  @Override
+  public void onUpdateVolume(float gain) {
+    for (AudioTrackBridge audioTrackBridge : audioTrackBridgeList) {
+      audioTrackBridge.setVolume(gain);
+    }
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  AudioTrackBridge createAudioTrackBridge(
+      int sampleType, int sampleRate, int channelCount, int framesPerChannel) {
+    AudioTrackBridge audioTrackBridge =
+        new AudioTrackBridge(sampleType, sampleRate, channelCount, framesPerChannel);
+    if (!audioTrackBridge.isAudioTrackValid()) {
+      Log.e(TAG, "AudioTrackBridge has invalid audio track");
+      return null;
+    }
+    audioTrackBridgeList.add(audioTrackBridge);
+    return audioTrackBridge;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  void destroyAudioTrackBridge(AudioTrackBridge audioTrackBridge) {
+    audioTrackBridge.release();
+    audioTrackBridgeList.remove(audioTrackBridge);
+  }
+
+  /** Returns the maximum number of HDMI channels. */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  int getMaxChannels() {
+    // The aac audio decoder on this platform will switch its output from 5.1
+    // to stereo right before providing the first output buffer when
+    // attempting to decode 5.1 input.  Since this heavily violates invariants
+    // of the shared starboard player framework, disable 5.1 on this platform.
+    // It is expected that we will be able to resolve this issue with Xiaomi
+    // by Android P, so only do this workaround for SDK versions < 27.
+    if (android.os.Build.MODEL.equals("MIBOX3") && android.os.Build.VERSION.SDK_INT < 27) {
+      return 2;
+    }
+
+    if (Build.VERSION.SDK_INT >= 23) {
+      return getMaxChannelsV23();
+    }
+    return 2;
+  }
+
+  /** Returns the maximum number of HDMI channels for API 23 and above. */
+  @TargetApi(23)
+  private int getMaxChannelsV23() {
+    AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+    AudioDeviceInfo[] deviceInfos = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS);
+    int maxChannels = 2;
+    for (AudioDeviceInfo info : deviceInfos) {
+      int type = info.getType();
+      if (type == AudioDeviceInfo.TYPE_HDMI || type == AudioDeviceInfo.TYPE_HDMI_ARC) {
+        int[] channelCounts = info.getChannelCounts();
+        if (channelCounts.length == 0) {
+          // An empty array indicates that the device supports arbitrary channel masks.
+          return 8;
+        }
+        for (int count : channelCounts) {
+          maxChannels = Math.max(maxChannels, count);
+        }
+      }
+    }
+    return maxChannels;
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/AudioTrackBridge.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/AudioTrackBridge.java
new file mode 100644
index 0000000..54d00ac
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/AudioTrackBridge.java
@@ -0,0 +1,204 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.media;
+
+import static dev.cobalt.media.Log.TAG;
+
+import android.media.AudioAttributes;
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioTimestamp;
+import android.media.AudioTrack;
+import android.os.Build;
+import dev.cobalt.util.Log;
+import dev.cobalt.util.UsedByNative;
+import java.nio.ByteBuffer;
+
+/** A wrapper of the android AudioTrack class. */
+@UsedByNative
+public class AudioTrackBridge {
+  private AudioTrack audioTrack;
+  private AudioTimestamp audioTimestamp = new AudioTimestamp();
+  private long maxFramePositionSoFar = 0;
+
+  public AudioTrackBridge(int sampleType, int sampleRate, int channelCount, int framesPerChannel) {
+    int channelConfig;
+    switch (channelCount) {
+      case 1:
+        channelConfig = AudioFormat.CHANNEL_OUT_MONO;
+        break;
+      case 2:
+        channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
+        break;
+      case 6:
+        channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
+        break;
+      default:
+        throw new RuntimeException("Unsupported channel count: " + channelCount);
+    }
+
+    AudioAttributes attributes =
+        new AudioAttributes.Builder()
+            .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
+            .setUsage(AudioAttributes.USAGE_MEDIA)
+            .build();
+    AudioFormat format =
+        new AudioFormat.Builder()
+            .setEncoding(sampleType)
+            .setSampleRate(sampleRate)
+            .setChannelMask(channelConfig)
+            .build();
+
+    int minBufferSizeBytes = AudioTrack.getMinBufferSize(sampleRate, channelConfig, sampleType);
+    int audioTrackBufferSize = minBufferSizeBytes;
+    // Use framesPerChannel to determine the buffer size.  To use a large buffer on a small
+    // framesPerChannel may lead to audio playback not able to start.
+    while (audioTrackBufferSize < framesPerChannel) {
+      audioTrackBufferSize *= 2;
+    }
+    while (audioTrackBufferSize > 0) {
+      try {
+        audioTrack =
+            new AudioTrack(
+                attributes,
+                format,
+                audioTrackBufferSize,
+                AudioTrack.MODE_STREAM,
+                AudioManager.AUDIO_SESSION_ID_GENERATE);
+      } catch (Exception e) {
+        audioTrack = null;
+      }
+      // AudioTrack ctor can fail in multiple, platform specific ways, so do a thorough check
+      // before proceed.
+      if (audioTrack != null && audioTrack.getState() == AudioTrack.STATE_INITIALIZED) {
+        break;
+      }
+      audioTrackBufferSize /= 2;
+    }
+    Log.i(
+        TAG,
+        String.format(
+            "AudioTrack created with buffer size %d.  The minimum buffer size is %d.",
+            audioTrackBufferSize, minBufferSizeBytes));
+  }
+
+  public Boolean isAudioTrackValid() {
+    return audioTrack != null;
+  }
+
+  public void release() {
+    if (audioTrack != null) {
+      audioTrack.release();
+    }
+    audioTrack = null;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public int setVolume(float gain) {
+    if (audioTrack == null) {
+      Log.e(TAG, "Unable to setVolume with NULL audio track.");
+      return 0;
+    }
+    return audioTrack.setVolume(gain);
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private void play() {
+    if (audioTrack == null) {
+      Log.e(TAG, "Unable to play with NULL audio track.");
+      return;
+    }
+    audioTrack.play();
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private void pause() {
+    if (audioTrack == null) {
+      Log.e(TAG, "Unable to pause with NULL audio track.");
+      return;
+    }
+    audioTrack.pause();
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private void flush() {
+    if (audioTrack == null) {
+      Log.e(TAG, "Unable to flush with NULL audio track.");
+      return;
+    }
+    audioTrack.flush();
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private int write(byte[] audioData, int sizeInBytes) {
+    if (audioTrack == null) {
+      Log.e(TAG, "Unable to write with NULL audio track.");
+      return 0;
+    }
+    if (Build.VERSION.SDK_INT >= 23) {
+      return audioTrack.write(audioData, 0, sizeInBytes, AudioTrack.WRITE_NON_BLOCKING);
+    } else {
+      ByteBuffer byteBuffer = ByteBuffer.wrap(audioData);
+      return audioTrack.write(byteBuffer, sizeInBytes, AudioTrack.WRITE_NON_BLOCKING);
+    }
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private int write(float[] audioData, int sizeInFloats) {
+    if (audioTrack == null) {
+      Log.e(TAG, "Unable to write with NULL audio track.");
+      return 0;
+    }
+    return audioTrack.write(audioData, 0, sizeInFloats, AudioTrack.WRITE_NON_BLOCKING);
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private AudioTimestamp getAudioTimestamp() {
+    // TODO: Consider calling with TIMEBASE_MONOTONIC and returning that
+    // information to the starboard audio sink.
+    if (audioTrack == null) {
+      Log.e(TAG, "Unable to getAudioTimestamp with NULL audio track.");
+      return audioTimestamp;
+    }
+    if (audioTrack.getTimestamp(audioTimestamp)) {
+      // This conversion is safe, as only the lower bits will be set, since we
+      // called |getTimestamp| without a timebase.
+      // https://developer.android.com/reference/android/media/AudioTimestamp.html#framePosition
+      audioTimestamp.framePosition = (int) audioTimestamp.framePosition;
+    } else {
+      // Time stamps haven't been updated yet, assume playback hasn't started.
+      audioTimestamp.framePosition = 0;
+      audioTimestamp.nanoTime = System.nanoTime();
+    }
+
+    // TODO: This is required for correctness of the audio sink, because
+    // otherwise we would be going back in time. Investigate the impact it has
+    // on playback.  All empirical measurements so far suggest that it should
+    // be negligible.
+    if (audioTimestamp.framePosition < maxFramePositionSoFar) {
+      audioTimestamp.framePosition = maxFramePositionSoFar;
+    }
+    maxFramePositionSoFar = audioTimestamp.framePosition;
+
+    return audioTimestamp;
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/CaptionSettings.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/CaptionSettings.java
new file mode 100644
index 0000000..43d6997
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/CaptionSettings.java
@@ -0,0 +1,49 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.media;
+
+import android.view.accessibility.CaptioningManager;
+import dev.cobalt.util.UsedByNative;
+
+/**
+ * Captures the system Caption style in properties as needed by the Starboard implementation.
+ */
+public class CaptionSettings {
+
+  @UsedByNative public final boolean isEnabled;
+  @UsedByNative public final float fontScale;
+  @UsedByNative public final int edgeType;
+  @UsedByNative public final boolean hasEdgeType;
+  @UsedByNative public final int foregroundColor;
+  @UsedByNative public final boolean hasForegroundColor;
+  @UsedByNative public final int backgroundColor;
+  @UsedByNative public final boolean hasBackgroundColor;
+  @UsedByNative public final int windowColor;
+  @UsedByNative public final boolean hasWindowColor;
+
+  public CaptionSettings(CaptioningManager cm) {
+    CaptioningManager.CaptionStyle style = cm.getUserStyle();
+    isEnabled = cm.isEnabled();
+    fontScale = cm.getFontScale();
+    edgeType = style.edgeType;
+    hasEdgeType = style.hasEdgeType();
+    foregroundColor = style.foregroundColor;
+    hasForegroundColor = style.hasForegroundColor();
+    backgroundColor = style.backgroundColor;
+    hasBackgroundColor = style.hasBackgroundColor();
+    windowColor = style.windowColor;
+    hasWindowColor = style.hasWindowColor();
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/CobaltMediaSession.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/CobaltMediaSession.java
new file mode 100644
index 0000000..8ca2a31
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/CobaltMediaSession.java
@@ -0,0 +1,440 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.media;
+
+import static dev.cobalt.media.Log.TAG;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.media.AudioAttributes;
+import android.media.AudioAttributes.Builder;
+import android.media.AudioFocusRequest;
+import android.media.AudioManager;
+import android.media.MediaMetadata;
+import android.media.session.MediaSession;
+import android.media.session.PlaybackState;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.WindowManager;
+import dev.cobalt.util.DisplayUtil;
+import dev.cobalt.util.Holder;
+import dev.cobalt.util.Log;
+
+/**
+ * Cobalt MediaSession glue, as well as collection of state and logic to switch on/off Android OS
+ * features used in media playback, such as audio focus, "KEEP_SCREEN_ON" mode, and "visible
+ * behind".
+ */
+public class CobaltMediaSession
+    implements AudioManager.OnAudioFocusChangeListener, ArtworkLoader.Callback {
+
+  // We do handle transport controls and set this flag on all API levels, even though it's
+  // deprecated and unnecessary on API 26+.
+  @SuppressWarnings("deprecation")
+  private static final int MEDIA_SESSION_FLAG_HANDLES_TRANSPORT_CONTROLS =
+      MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS;
+
+  private AudioFocusRequest audioFocusRequest;
+
+  interface UpdateVolumeListener {
+    /** Called when there is a change in audio focus. */
+    void onUpdateVolume(float gain);
+  }
+
+  /**
+   * When losing audio focus with the option of ducking, we reduce the volume to 10%. This arbitrary
+   * number is what YouTube Android Player infrastructure uses.
+   */
+  private static final float AUDIO_FOCUS_DUCK_LEVEL = 0.1f;
+
+  private final Handler mainHandler = new Handler(Looper.getMainLooper());
+
+  private final Context context;
+  private final Holder<Activity> activityHolder;
+
+  private final UpdateVolumeListener volumeListener;
+  private final ArtworkLoader artworkLoader;
+  private MediaSession mediaSession;
+
+  // We re-use the builder to hold onto the most recent metadata and add artwork later.
+  private MediaMetadata.Builder metadataBuilder = new MediaMetadata.Builder();
+  // We re-use the builder to hold onto the most recent playback state.
+  private PlaybackState.Builder playbackStateBuilder = new PlaybackState.Builder();
+
+  // Duplicated in starboard/android/shared/android_media_session_client.h
+  // PlaybackState
+  private static final int PLAYBACK_STATE_PLAYING = 0;
+  private static final int PLAYBACK_STATE_PAUSED = 1;
+  private static final int PLAYBACK_STATE_NONE = 2;
+  private static final String[] PLAYBACK_STATE_NAME = {"playing", "paused", "none"};
+
+  // Accessed on the main looper thread only.
+  private int playbackState = PLAYBACK_STATE_NONE;
+  private boolean transientPause = false;
+  private boolean suspended = true;
+
+  public CobaltMediaSession(
+      Context context, Holder<Activity> activityHolder, UpdateVolumeListener volumeListener) {
+    this.context = context;
+    this.activityHolder = activityHolder;
+
+    this.volumeListener = volumeListener;
+    artworkLoader = new ArtworkLoader(this, DisplayUtil.getDisplaySize(context));
+    setMediaSession();
+  }
+
+  private void setMediaSession() {
+    mediaSession = new MediaSession(context, TAG);
+    mediaSession.setFlags(MEDIA_SESSION_FLAG_HANDLES_TRANSPORT_CONTROLS);
+    mediaSession.setCallback(
+        new MediaSession.Callback() {
+          @Override
+          public void onFastForward() {
+            Log.i(TAG, "MediaSession action: FAST FORWARD");
+            nativeInvokeAction(PlaybackState.ACTION_FAST_FORWARD);
+          }
+
+          @Override
+          public void onPause() {
+            Log.i(TAG, "MediaSession action: PAUSE");
+            nativeInvokeAction(PlaybackState.ACTION_PAUSE);
+          }
+
+          @Override
+          public void onPlay() {
+            Log.i(TAG, "MediaSession action: PLAY");
+            nativeInvokeAction(PlaybackState.ACTION_PLAY);
+          }
+
+          @Override
+          public void onRewind() {
+            Log.i(TAG, "MediaSession action: REWIND");
+            nativeInvokeAction(PlaybackState.ACTION_REWIND);
+          }
+
+          @Override
+          public void onSkipToNext() {
+            Log.i(TAG, "MediaSession action: SKIP NEXT");
+            nativeInvokeAction(PlaybackState.ACTION_SKIP_TO_NEXT);
+          }
+
+          @Override
+          public void onSkipToPrevious() {
+            Log.i(TAG, "MediaSession action: SKIP PREVIOUS");
+            nativeInvokeAction(PlaybackState.ACTION_SKIP_TO_PREVIOUS);
+          }
+
+          @Override
+          public void onSeekTo(long pos) {
+            Log.i(TAG, "MediaSession action: SEEK " + pos);
+            nativeInvokeAction(PlaybackState.ACTION_SEEK_TO, pos);
+          }
+        });
+    // |metadataBuilder| may still have no fields at this point, yielding empty metadata.
+    mediaSession.setMetadata(metadataBuilder.build());
+    // |playbackStateBuilder| may still have no fields at this point.
+    mediaSession.setPlaybackState(playbackStateBuilder.build());
+  }
+
+  private static void checkMainLooperThread() {
+    if (Looper.getMainLooper() != Looper.myLooper()) {
+      throw new RuntimeException("Must be on main thread");
+    }
+  }
+
+  /**
+   * Sets system media resources active or not according to whether media is playing. This is
+   * idempotent as it may be called multiple times during the course of a media session.
+   */
+  private void configureMediaFocus(int playbackState) {
+    checkMainLooperThread();
+    if (transientPause && playbackState == PLAYBACK_STATE_PAUSED) {
+      Log.i(TAG, "Media focus: paused (transient)");
+      // Don't release media focus while transiently paused, otherwise we won't get audiofocus back
+      // when the transient condition ends and we would leave playback paused.
+      return;
+    }
+    Log.i(TAG, "Media focus: " + PLAYBACK_STATE_NAME[playbackState]);
+    wakeLock(playbackState == PLAYBACK_STATE_PLAYING);
+    audioFocus(playbackState == PLAYBACK_STATE_PLAYING);
+
+    boolean activating = playbackState != PLAYBACK_STATE_NONE && !mediaSession.isActive();
+    boolean deactivating = playbackState == PLAYBACK_STATE_NONE && mediaSession.isActive();
+    if (activating) {
+      // Resuming or new playbacks land here.
+      setMediaSession();
+    }
+    mediaSession.setActive(playbackState != PLAYBACK_STATE_NONE);
+    if (deactivating) {
+      // Suspending lands here.
+      mediaSession.release();
+    }
+  }
+
+  private void wakeLock(boolean lock) {
+    Activity activity = activityHolder.get();
+    if (activity == null) {
+      return;
+    }
+    if (lock) {
+      activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+    } else {
+      activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+    }
+  }
+
+  private void audioFocus(boolean focus) {
+    if (focus) {
+      int res;
+      if (Build.VERSION.SDK_INT < 26) {
+        res = requestAudioFocus();
+      } else {
+        res = requestAudioFocusV26();
+      }
+      // This shouldn't happen, but pause playback to be nice if it does.
+      if (res != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
+        Log.w(TAG, "Audiofocus action: PAUSE (not granted)");
+        nativeInvokeAction(PlaybackState.ACTION_PAUSE);
+      }
+    } else {
+      if (Build.VERSION.SDK_INT < 26) {
+        abandonAudioFocus();
+      } else {
+        abandonAudioFocusV26();
+      }
+    }
+  }
+
+  @SuppressWarnings("deprecation")
+  private int requestAudioFocus() {
+    return getAudioManager()
+        .requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
+  }
+
+  @TargetApi(26)
+  private int requestAudioFocusV26() {
+    if (audioFocusRequest == null) {
+      AudioAttributes audioAtrributes =
+          new Builder().setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build();
+      audioFocusRequest =
+          new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
+              .setOnAudioFocusChangeListener(this)
+              .setAudioAttributes(audioAtrributes)
+              .build();
+    }
+    return getAudioManager().requestAudioFocus(audioFocusRequest);
+  }
+
+  @SuppressWarnings("deprecation")
+  private void abandonAudioFocus() {
+    getAudioManager().abandonAudioFocus(this);
+  }
+
+  @TargetApi(26)
+  private void abandonAudioFocusV26() {
+    if (audioFocusRequest != null) {
+      getAudioManager().abandonAudioFocusRequest(audioFocusRequest);
+    }
+  }
+
+  /** AudioManager.OnAudioFocusChangeListener implementation. */
+  @Override
+  public void onAudioFocusChange(int focusChange) {
+    String logExtra = "";
+    switch (focusChange) {
+      case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
+        logExtra = " (transient)";
+        // fall through
+      case AudioManager.AUDIOFOCUS_LOSS:
+        Log.i(TAG, "Audiofocus loss" + logExtra);
+        if (playbackState == PLAYBACK_STATE_PLAYING) {
+          Log.i(TAG, "Audiofocus action: PAUSE");
+          nativeInvokeAction(PlaybackState.ACTION_PAUSE);
+        }
+        break;
+      case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
+        Log.i(TAG, "Audiofocus duck");
+        // Lower the volume, keep current play state.
+        // Starting with API 26 the system does automatic ducking without calling our listener,
+        // but we still need this for API < 26.
+        volumeListener.onUpdateVolume(AUDIO_FOCUS_DUCK_LEVEL);
+        break;
+      case AudioManager.AUDIOFOCUS_GAIN:
+        Log.i(TAG, "Audiofocus gain");
+        // The app has been granted audio focus (again). Raise volume to normal,
+        // restart playback if necessary.
+        volumeListener.onUpdateVolume(1.0f);
+        if (transientPause && playbackState == PLAYBACK_STATE_PAUSED) {
+          Log.i(TAG, "Audiofocus action: PLAY");
+          nativeInvokeAction(PlaybackState.ACTION_PLAY);
+        }
+        break;
+      default: // fall out
+    }
+
+    // Keep track of whether we're currently paused because of a transient loss of audiofocus.
+    transientPause = (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT);
+  }
+
+  private AudioManager getAudioManager() {
+    return (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+  }
+
+  public void resume() {
+    mainHandler.post(
+        new Runnable() {
+          @Override
+          public void run() {
+            resumeInternal();
+          }
+        });
+  }
+
+  private void resumeInternal() {
+    checkMainLooperThread();
+    suspended = false;
+    // Undoing what may have been done in suspendInternal().
+    configureMediaFocus(playbackState);
+  }
+
+  public void suspend() {
+    mainHandler.post(
+        new Runnable() {
+          @Override
+          public void run() {
+            suspendInternal();
+          }
+        });
+  }
+
+  private void suspendInternal() {
+    checkMainLooperThread();
+    suspended = true;
+
+    // We generally believe the HTML5 app playback state as the source of truth for configuring
+    // media focus since only it can know about a momentary pause between videos in a playlist, or
+    // other autoplay scenario when we should keep media focus. However, when suspending, any
+    // active SbPlayer is destroyed and we release media focus, even if the HTML5 app still thinks
+    // it's in a playing state. We'll configure it again in resumeInternal() and the HTML5 app will
+    // be none the wiser.
+    playbackStateBuilder.setState(
+        playbackState,
+        PlaybackState.PLAYBACK_POSITION_UNKNOWN,
+        playbackState == PLAYBACK_STATE_PLAYING ? 1.0f : 0.0f);
+    configureMediaFocus(PLAYBACK_STATE_NONE);
+  }
+
+  private static void nativeInvokeAction(long action) {
+    nativeInvokeAction(action, 0);
+  }
+
+  private static native void nativeInvokeAction(long action, long seekMs);
+
+  public void updateMediaSession(
+      final int playbackState,
+      final long actions,
+      final long positionMs,
+      final float speed,
+      final String title,
+      final String artist,
+      final String album,
+      final MediaImage[] artwork) {
+    mainHandler.post(
+        new Runnable() {
+          @Override
+          public void run() {
+            updateMediaSessionInternal(
+                playbackState, actions, positionMs, speed,
+                title, artist, album, artwork);
+          }
+        });
+  }
+
+  /** Called on main looper thread when media session changes. */
+  private void updateMediaSessionInternal(
+      int playbackState,
+      long actions,
+      long positionMs,
+      float speed,
+      String title,
+      String artist,
+      String album,
+      MediaImage[] artwork) {
+    checkMainLooperThread();
+
+    // Always keep track of what the HTML5 app thinks the playback state is so we can configure the
+    // media focus correctly, either immediately or when resuming from being suspended.
+    this.playbackState = playbackState;
+
+    // Don't update anything while suspended.
+    if (suspended) {
+      Log.i(TAG, "Playback state change while suspended: " + PLAYBACK_STATE_NAME[playbackState]);
+      return;
+    }
+
+    configureMediaFocus(playbackState);
+
+    // Ignore updates to the MediaSession metadata if playback is stopped.
+    if (playbackState == PLAYBACK_STATE_NONE) {
+      return;
+    }
+
+    int androidPlaybackState;
+    String stateName;
+    switch (playbackState) {
+      case PLAYBACK_STATE_PLAYING:
+        androidPlaybackState = PlaybackState.STATE_PLAYING;
+        stateName = "PLAYING";
+        break;
+      case PLAYBACK_STATE_PAUSED:
+        androidPlaybackState = PlaybackState.STATE_PAUSED;
+        stateName = "PAUSED";
+        break;
+      case PLAYBACK_STATE_NONE:
+      default:
+        androidPlaybackState = PlaybackState.STATE_NONE;
+        stateName = "NONE";
+        break;
+    }
+
+    Log.i(TAG, String.format(
+        "MediaSession state: %s, position: %d ms, speed: %f x", stateName, positionMs, speed));
+
+    playbackStateBuilder =
+        new PlaybackState.Builder()
+            .setActions(actions)
+            .setState(androidPlaybackState, positionMs, speed);
+    mediaSession.setPlaybackState(playbackStateBuilder.build());
+
+    // Reset the metadata to make sure we don't retain any fields from previous playback.
+    metadataBuilder = new MediaMetadata.Builder();
+    metadataBuilder
+        .putString(MediaMetadata.METADATA_KEY_TITLE, title)
+        .putString(MediaMetadata.METADATA_KEY_ARTIST, artist)
+        .putString(MediaMetadata.METADATA_KEY_ALBUM, album)
+        .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, artworkLoader.getOrLoadArtwork(artwork));
+    // Update the metadata as soon as we can - even before artwork is loaded.
+    mediaSession.setMetadata(metadataBuilder.build());
+  }
+
+  @Override
+  public void onArtworkLoaded(Bitmap bitmap) {
+    metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmap);
+    mediaSession.setMetadata(metadataBuilder.build());
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/Log.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/Log.java
new file mode 100644
index 0000000..44e51b8
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/Log.java
@@ -0,0 +1,24 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.media;
+
+/**
+ * Common definitions for logging in the media package.
+ */
+public class Log {
+  public static final String TAG = "starboard_media";
+
+  private Log() {}
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/MediaCodecBridge.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/MediaCodecBridge.java
new file mode 100644
index 0000000..5fd0a8b
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/MediaCodecBridge.java
@@ -0,0 +1,953 @@
+// Copyright 2013 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// Modifications Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.media;
+
+import static dev.cobalt.media.Log.TAG;
+
+import android.media.AudioFormat;
+import android.media.MediaCodec;
+import android.media.MediaCodec.CryptoInfo;
+import android.media.MediaCodecInfo.VideoCapabilities;
+import android.media.MediaCrypto;
+import android.media.MediaFormat;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.Surface;
+import dev.cobalt.util.Log;
+import dev.cobalt.util.UsedByNative;
+import java.nio.ByteBuffer;
+
+/** A wrapper of the MediaCodec class. */
+@SuppressWarnings("unused")
+@UsedByNative
+class MediaCodecBridge {
+  // Error code for MediaCodecBridge. Keep this value in sync with
+  // MEDIA_CODEC_* values in media_codec_bridge.h.
+  private static final int MEDIA_CODEC_OK = 0;
+  private static final int MEDIA_CODEC_DEQUEUE_INPUT_AGAIN_LATER = 1;
+  private static final int MEDIA_CODEC_DEQUEUE_OUTPUT_AGAIN_LATER = 2;
+  private static final int MEDIA_CODEC_OUTPUT_BUFFERS_CHANGED = 3;
+  private static final int MEDIA_CODEC_OUTPUT_FORMAT_CHANGED = 4;
+  private static final int MEDIA_CODEC_INPUT_END_OF_STREAM = 5;
+  private static final int MEDIA_CODEC_OUTPUT_END_OF_STREAM = 6;
+  private static final int MEDIA_CODEC_NO_KEY = 7;
+  private static final int MEDIA_CODEC_INSUFFICIENT_OUTPUT_PROTECTION = 8;
+  private static final int MEDIA_CODEC_ABORT = 9;
+  private static final int MEDIA_CODEC_ERROR = 10;
+
+  // After a flush(), dequeueOutputBuffer() can often produce empty presentation timestamps
+  // for several frames. As a result, the player may find that the time does not increase
+  // after decoding a frame. To detect this, we check whether the presentation timestamp from
+  // dequeueOutputBuffer() is larger than input_timestamp - MAX_PRESENTATION_TIMESTAMP_SHIFT_US
+  // after a flush. And we set the presentation timestamp from dequeueOutputBuffer() to be
+  // non-decreasing for the remaining frames.
+  private static final long MAX_PRESENTATION_TIMESTAMP_SHIFT_US = 100000;
+
+  // We use only one output audio format (PCM16) that has 2 bytes per sample
+  private static final int PCM16_BYTES_PER_SAMPLE = 2;
+
+  // TODO: Use MediaFormat constants when part of the public API.
+  private static final String KEY_CROP_LEFT = "crop-left";
+  private static final String KEY_CROP_RIGHT = "crop-right";
+  private static final String KEY_CROP_BOTTOM = "crop-bottom";
+  private static final String KEY_CROP_TOP = "crop-top";
+
+  private static final int BITRATE_ADJUSTMENT_FPS = 30;
+  private static final int MAXIMUM_INITIAL_FPS = 30;
+
+  private MediaCodec mMediaCodec;
+  private boolean mFlushed;
+  private long mLastPresentationTimeUs;
+  private final String mMime;
+  private boolean mAdaptivePlaybackSupported;
+
+  // Functions that require this will be called frequently in a tight loop.
+  // Only create one of these and reuse it to avoid excessive allocations,
+  // which would cause GC cycles long enough to impact playback.
+  private final MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
+
+  // Type of bitrate adjustment for video encoder.
+  public enum BitrateAdjustmentTypes {
+    // No adjustment - video encoder has no known bitrate problem.
+    NO_ADJUSTMENT,
+    // Framerate based bitrate adjustment is required - HW encoder does not use frame
+    // timestamps to calculate frame bitrate budget and instead is relying on initial
+    // fps configuration assuming that all frames are coming at fixed initial frame rate.
+    FRAMERATE_ADJUSTMENT,
+  }
+
+  public static final class MimeTypes {
+    public static final String VIDEO_MP4 = "video/mp4";
+    public static final String VIDEO_WEBM = "video/webm";
+    public static final String VIDEO_H264 = "video/avc";
+    public static final String VIDEO_H265 = "video/hevc";
+    public static final String VIDEO_VP8 = "video/x-vnd.on2.vp8";
+    public static final String VIDEO_VP9 = "video/x-vnd.on2.vp9";
+  }
+
+  private BitrateAdjustmentTypes mBitrateAdjustmentType = BitrateAdjustmentTypes.NO_ADJUSTMENT;
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public static class DequeueInputResult {
+    private int mStatus;
+    private int mIndex;
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private DequeueInputResult() {
+      mStatus = MEDIA_CODEC_ERROR;
+      mIndex = -1;
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private DequeueInputResult(int status, int index) {
+      mStatus = status;
+      mIndex = index;
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private int status() {
+      return mStatus;
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private int index() {
+      return mIndex;
+    }
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private static class DequeueOutputResult {
+    private int mStatus;
+    private int mIndex;
+    private int mFlags;
+    private int mOffset;
+    private long mPresentationTimeMicroseconds;
+    private int mNumBytes;
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private DequeueOutputResult() {
+      mStatus = MEDIA_CODEC_ERROR;
+      mIndex = -1;
+      mFlags = 0;
+      mOffset = 0;
+      mPresentationTimeMicroseconds = 0;
+      mNumBytes = 0;
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private DequeueOutputResult(
+        int status,
+        int index,
+        int flags,
+        int offset,
+        long presentationTimeMicroseconds,
+        int numBytes) {
+      mStatus = status;
+      mIndex = index;
+      mFlags = flags;
+      mOffset = offset;
+      mPresentationTimeMicroseconds = presentationTimeMicroseconds;
+      mNumBytes = numBytes;
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private int status() {
+      return mStatus;
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private int index() {
+      return mIndex;
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private int flags() {
+      return mFlags;
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private int offset() {
+      return mOffset;
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private long presentationTimeMicroseconds() {
+      return mPresentationTimeMicroseconds;
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private int numBytes() {
+      return mNumBytes;
+    }
+  }
+
+  /** A wrapper around a MediaFormat. */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private static class GetOutputFormatResult {
+    private int mStatus;
+    // May be null if mStatus is not MEDIA_CODEC_OK.
+    private MediaFormat mFormat;
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private GetOutputFormatResult() {
+      mStatus = MEDIA_CODEC_ERROR;
+      mFormat = null;
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private GetOutputFormatResult(int status, MediaFormat format) {
+      mStatus = status;
+      mFormat = format;
+    }
+
+    private boolean formatHasCropValues() {
+      return mFormat.containsKey(KEY_CROP_RIGHT)
+          && mFormat.containsKey(KEY_CROP_LEFT)
+          && mFormat.containsKey(KEY_CROP_BOTTOM)
+          && mFormat.containsKey(KEY_CROP_TOP);
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private int status() {
+      return mStatus;
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private int width() {
+      return formatHasCropValues()
+          ? mFormat.getInteger(KEY_CROP_RIGHT) - mFormat.getInteger(KEY_CROP_LEFT) + 1
+          : mFormat.getInteger(MediaFormat.KEY_WIDTH);
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private int height() {
+      return formatHasCropValues()
+          ? mFormat.getInteger(KEY_CROP_BOTTOM) - mFormat.getInteger(KEY_CROP_TOP) + 1
+          : mFormat.getInteger(MediaFormat.KEY_HEIGHT);
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private int sampleRate() {
+      return mFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE);
+    }
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    private int channelCount() {
+      return mFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
+    }
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private static class ColorInfo {
+    private static final int MAX_CHROMATICITY = 50000; // Defined in CTA-861.3.
+    private static final int DEFAULT_MAX_CLL = 1000;
+    private static final int DEFAULT_MAX_FALL = 200;
+
+    public int colorRange;
+    public int colorStandard;
+    public int colorTransfer;
+    public ByteBuffer hdrStaticInfo;
+
+    @SuppressWarnings("unused")
+    @UsedByNative
+    ColorInfo(
+        int colorRange,
+        int colorStandard,
+        int colorTransfer,
+        float primaryRChromaticityX,
+        float primaryRChromaticityY,
+        float primaryGChromaticityX,
+        float primaryGChromaticityY,
+        float primaryBChromaticityX,
+        float primaryBChromaticityY,
+        float whitePointChromaticityX,
+        float whitePointChromaticityY,
+        float maxMasteringLuminance,
+        float minMasteringLuminance) {
+      this.colorRange = colorRange;
+      this.colorStandard = colorStandard;
+      this.colorTransfer = colorTransfer;
+
+      // This logic is inspired by
+      // https://github.com/google/ExoPlayer/blob/deb9b301b2c7ef66fdd7d8a3e58298a79ba9c619/library/core/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java#L1803.
+      byte[] hdrStaticInfoData = new byte[25];
+      ByteBuffer hdrStaticInfo = ByteBuffer.wrap(hdrStaticInfoData);
+      hdrStaticInfo.put((byte) 0);
+      hdrStaticInfo.putShort((short) ((primaryRChromaticityX * MAX_CHROMATICITY) + 0.5f));
+      hdrStaticInfo.putShort((short) ((primaryRChromaticityY * MAX_CHROMATICITY) + 0.5f));
+      hdrStaticInfo.putShort((short) ((primaryGChromaticityX * MAX_CHROMATICITY) + 0.5f));
+      hdrStaticInfo.putShort((short) ((primaryGChromaticityY * MAX_CHROMATICITY) + 0.5f));
+      hdrStaticInfo.putShort((short) ((primaryBChromaticityX * MAX_CHROMATICITY) + 0.5f));
+      hdrStaticInfo.putShort((short) ((primaryBChromaticityY * MAX_CHROMATICITY) + 0.5f));
+      hdrStaticInfo.putShort((short) ((whitePointChromaticityX * MAX_CHROMATICITY) + 0.5f));
+      hdrStaticInfo.putShort((short) ((whitePointChromaticityY * MAX_CHROMATICITY) + 0.5f));
+      hdrStaticInfo.putShort((short) (maxMasteringLuminance + 0.5f));
+      hdrStaticInfo.putShort((short) (minMasteringLuminance + 0.5f));
+      hdrStaticInfo.putShort((short) DEFAULT_MAX_CLL);
+      hdrStaticInfo.putShort((short) DEFAULT_MAX_FALL);
+      this.hdrStaticInfo = hdrStaticInfo;
+    }
+  }
+
+  private MediaCodecBridge(
+      MediaCodec mediaCodec,
+      String mime,
+      boolean adaptivePlaybackSupported,
+      BitrateAdjustmentTypes bitrateAdjustmentType) {
+    if (mediaCodec == null) {
+      throw new IllegalArgumentException();
+    }
+    mMediaCodec = mediaCodec;
+    mMime = mime; // TODO: Delete the unused mMime field
+    mLastPresentationTimeUs = 0;
+    mFlushed = true;
+    mAdaptivePlaybackSupported = adaptivePlaybackSupported;
+    mBitrateAdjustmentType = bitrateAdjustmentType;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public static MediaCodecBridge createAudioMediaCodecBridge(
+      String mime,
+      boolean isSecure,
+      boolean requireSoftwareCodec,
+      int sampleRate,
+      int channelCount,
+      MediaCrypto crypto) {
+    MediaCodec mediaCodec = null;
+    try {
+      String decoderName = MediaCodecUtil.findAudioDecoder(mime, 0);
+      if (decoderName.equals("")) {
+        Log.e(TAG, String.format("Failed to find decoder: %s, isSecure: %s", mime, isSecure));
+        return null;
+      }
+      Log.i(TAG, String.format("Creating \"%s\" decoder.", decoderName));
+      mediaCodec = MediaCodec.createByCodecName(decoderName);
+    } catch (Exception e) {
+      Log.e(TAG, String.format("Failed to create MediaCodec: %s, isSecure: %s", mime, isSecure), e);
+      return null;
+    }
+    if (mediaCodec == null) {
+      return null;
+    }
+    MediaCodecBridge bridge =
+        new MediaCodecBridge(mediaCodec, mime, true, BitrateAdjustmentTypes.NO_ADJUSTMENT);
+
+    MediaFormat mediaFormat = createAudioFormat(mime, sampleRate, channelCount);
+    setFrameHasADTSHeader(mediaFormat);
+    if (!bridge.configureAudio(mediaFormat, crypto, 0)) {
+      Log.e(TAG, "Failed to configure audio codec.");
+      bridge.release();
+      return null;
+    }
+    if (!bridge.start()) {
+      Log.e(TAG, "Failed to start audio codec.");
+      bridge.release();
+      return null;
+    }
+
+    return bridge;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public static MediaCodecBridge createVideoMediaCodecBridge(
+      String mime,
+      boolean isSecure,
+      boolean requireSoftwareCodec,
+      int width,
+      int height,
+      Surface surface,
+      MediaCrypto crypto,
+      ColorInfo colorInfo) {
+    MediaCodec mediaCodec = null;
+
+    boolean findHDRDecoder = android.os.Build.VERSION.SDK_INT >= 24 && colorInfo != null;
+    // On first pass, try to find a decoder with HDR if the color info is non-null.
+    MediaCodecUtil.FindVideoDecoderResult findVideoDecoderResult =
+        MediaCodecUtil.findVideoDecoder(mime, isSecure, 0, 0, 0, 0, findHDRDecoder);
+    if (findVideoDecoderResult.name.equals("") && findHDRDecoder) {
+      // On second pass, forget HDR.
+      findVideoDecoderResult = MediaCodecUtil.findVideoDecoder(mime, isSecure, 0, 0, 0, 0, false);
+    }
+    try {
+      String decoderName = findVideoDecoderResult.name;
+      if (decoderName.equals("") || findVideoDecoderResult.videoCapabilities == null) {
+        Log.e(TAG, String.format("Failed to find decoder: %s, isSecure: %s", mime, isSecure));
+        return null;
+      }
+      Log.i(TAG, String.format("Creating \"%s\" decoder.", decoderName));
+      mediaCodec = MediaCodec.createByCodecName(decoderName);
+    } catch (Exception e) {
+      Log.e(TAG, String.format("Failed to create MediaCodec: %s, isSecure: %s", mime, isSecure), e);
+      return null;
+    }
+    if (mediaCodec == null) {
+      return null;
+    }
+    MediaCodecBridge bridge =
+        new MediaCodecBridge(mediaCodec, mime, true, BitrateAdjustmentTypes.NO_ADJUSTMENT);
+    MediaFormat mediaFormat =
+        createVideoDecoderFormat(mime, width, height, findVideoDecoderResult.videoCapabilities);
+
+    boolean shouldConfigureHdr =
+        android.os.Build.VERSION.SDK_INT >= 24
+            && colorInfo != null
+            && MediaCodecUtil.isHdrCapableVp9Decoder(findVideoDecoderResult);
+    if (shouldConfigureHdr) {
+      Log.d(TAG, "Setting HDR info.");
+      mediaFormat.setInteger(MediaFormat.KEY_COLOR_TRANSFER, colorInfo.colorTransfer);
+      mediaFormat.setInteger(MediaFormat.KEY_COLOR_STANDARD, colorInfo.colorStandard);
+      mediaFormat.setInteger(MediaFormat.KEY_COLOR_RANGE, colorInfo.colorRange);
+      mediaFormat.setByteBuffer(MediaFormat.KEY_HDR_STATIC_INFO, colorInfo.hdrStaticInfo);
+    }
+
+    int maxWidth = findVideoDecoderResult.videoCapabilities.getSupportedWidths().getUpper();
+    int maxHeight = findVideoDecoderResult.videoCapabilities.getSupportedHeights().getUpper();
+    if (!bridge.configureVideo(mediaFormat, surface, crypto, 0, true, maxWidth, maxHeight)) {
+      Log.e(TAG, "Failed to configure video codec.");
+      bridge.release();
+      return null;
+    }
+    if (!bridge.start()) {
+      Log.e(TAG, "Failed to start video codec.");
+      bridge.release();
+      return null;
+    }
+
+    return bridge;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public void release() {
+    try {
+      String codecName = mMediaCodec.getName();
+      Log.w(TAG, "calling MediaCodec.release() on " + codecName);
+      mMediaCodec.release();
+    } catch (IllegalStateException e) {
+      // The MediaCodec is stuck in a wrong state, possibly due to losing
+      // the surface.
+      Log.e(TAG, "Cannot release media codec", e);
+    }
+    mMediaCodec = null;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private boolean start() {
+    try {
+      mMediaCodec.start();
+    } catch (IllegalStateException | IllegalArgumentException e) {
+      Log.e(TAG, "Cannot start the media codec", e);
+      return false;
+    }
+    return true;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private void dequeueInputBuffer(long timeoutUs, DequeueInputResult outDequeueInputResult) {
+    int status = MEDIA_CODEC_ERROR;
+    int index = -1;
+    try {
+      int indexOrStatus = mMediaCodec.dequeueInputBuffer(timeoutUs);
+      if (indexOrStatus >= 0) { // index!
+        status = MEDIA_CODEC_OK;
+        index = indexOrStatus;
+      } else if (indexOrStatus == MediaCodec.INFO_TRY_AGAIN_LATER) {
+        status = MEDIA_CODEC_DEQUEUE_INPUT_AGAIN_LATER;
+      } else {
+        throw new AssertionError("Unexpected index_or_status: " + indexOrStatus);
+      }
+    } catch (Exception e) {
+      Log.e(TAG, "Failed to dequeue input buffer", e);
+    }
+    outDequeueInputResult.mStatus = status;
+    outDequeueInputResult.mIndex = index;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private int flush() {
+    try {
+      mFlushed = true;
+      mMediaCodec.flush();
+    } catch (IllegalStateException e) {
+      Log.e(TAG, "Failed to flush MediaCodec", e);
+      return MEDIA_CODEC_ERROR;
+    }
+    return MEDIA_CODEC_OK;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private void stop() {
+    try {
+      mMediaCodec.stop();
+    } catch (IllegalStateException e) {
+      Log.e(TAG, "Failed to stop MediaCodec", e);
+    }
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private String getName() {
+    String codecName = "unknown";
+    try {
+      codecName = mMediaCodec.getName();
+    } catch (IllegalStateException e) {
+      Log.e(TAG, "Cannot get codec name", e);
+    }
+    return codecName;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private void getOutputFormat(GetOutputFormatResult outGetOutputFormatResult) {
+    MediaFormat format = null;
+    int status = MEDIA_CODEC_OK;
+    try {
+      format = mMediaCodec.getOutputFormat();
+    } catch (IllegalStateException e) {
+      Log.e(TAG, "Failed to get output format", e);
+      status = MEDIA_CODEC_ERROR;
+    }
+    outGetOutputFormatResult.mStatus = status;
+    outGetOutputFormatResult.mFormat = format;
+  }
+
+  /** Returns null if MediaCodec throws IllegalStateException. */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private ByteBuffer getInputBuffer(int index) {
+    try {
+      return mMediaCodec.getInputBuffer(index);
+    } catch (IllegalStateException e) {
+      Log.e(TAG, "Failed to get input buffer", e);
+      return null;
+    }
+  }
+
+  /** Returns null if MediaCodec throws IllegalStateException. */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private ByteBuffer getOutputBuffer(int index) {
+    try {
+      return mMediaCodec.getOutputBuffer(index);
+    } catch (IllegalStateException e) {
+      Log.e(TAG, "Failed to get output buffer", e);
+      return null;
+    }
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private int queueInputBuffer(
+      int index, int offset, int size, long presentationTimeUs, int flags) {
+    resetLastPresentationTimeIfNeeded(presentationTimeUs);
+    try {
+      mMediaCodec.queueInputBuffer(index, offset, size, presentationTimeUs, flags);
+    } catch (Exception e) {
+      Log.e(TAG, "Failed to queue input buffer", e);
+      return MEDIA_CODEC_ERROR;
+    }
+    return MEDIA_CODEC_OK;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private void setVideoBitrate(int bps, int frameRate) {
+    int targetBps = bps;
+    if (mBitrateAdjustmentType == BitrateAdjustmentTypes.FRAMERATE_ADJUSTMENT && frameRate > 0) {
+      targetBps = BITRATE_ADJUSTMENT_FPS * bps / frameRate;
+    }
+
+    Bundle b = new Bundle();
+    b.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, targetBps);
+    try {
+      mMediaCodec.setParameters(b);
+    } catch (IllegalStateException e) {
+      Log.e(TAG, "Failed to set MediaCodec parameters", e);
+    }
+    Log.v(TAG, "setVideoBitrate: input " + bps + "bps@" + frameRate + ", targetBps " + targetBps);
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private void requestKeyFrameSoon() {
+    Bundle b = new Bundle();
+    b.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0);
+    try {
+      mMediaCodec.setParameters(b);
+    } catch (IllegalStateException e) {
+      Log.e(TAG, "Failed to set MediaCodec parameters", e);
+    }
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private int queueSecureInputBuffer(
+      int index,
+      int offset,
+      byte[] iv,
+      byte[] keyId,
+      int[] numBytesOfClearData,
+      int[] numBytesOfEncryptedData,
+      int numSubSamples,
+      int cipherMode,
+      int patternEncrypt,
+      int patternSkip,
+      long presentationTimeUs) {
+    resetLastPresentationTimeIfNeeded(presentationTimeUs);
+    try {
+      boolean usesCbcs =
+          Build.VERSION.SDK_INT >= 24 && cipherMode == MediaCodec.CRYPTO_MODE_AES_CBC;
+
+      if (usesCbcs) {
+        Log.e(TAG, "Encryption scheme 'cbcs' not supported on this platform.");
+        return MEDIA_CODEC_ERROR;
+      }
+      CryptoInfo cryptoInfo = new CryptoInfo();
+      cryptoInfo.set(
+          numSubSamples, numBytesOfClearData, numBytesOfEncryptedData, keyId, iv, cipherMode);
+      if (patternEncrypt != 0 && patternSkip != 0) {
+        if (usesCbcs) {
+          // Above platform check ensured that setting the pattern is indeed supported.
+          // MediaCodecUtil.setPatternIfSupported(cryptoInfo, patternEncrypt, patternSkip);
+          Log.e(TAG, "Only AES_CTR is supported.");
+        } else {
+          Log.e(TAG, "Pattern encryption only supported for 'cbcs' scheme (CBC mode).");
+          return MEDIA_CODEC_ERROR;
+        }
+      }
+      mMediaCodec.queueSecureInputBuffer(index, offset, cryptoInfo, presentationTimeUs, 0);
+    } catch (MediaCodec.CryptoException e) {
+      int errorCode = e.getErrorCode();
+      if (errorCode == MediaCodec.CryptoException.ERROR_NO_KEY) {
+        Log.d(TAG, "Failed to queue secure input buffer: CryptoException.ERROR_NO_KEY");
+        return MEDIA_CODEC_NO_KEY;
+      } else if (errorCode == MediaCodec.CryptoException.ERROR_INSUFFICIENT_OUTPUT_PROTECTION) {
+        Log.d(
+            TAG,
+            "Failed to queue secure input buffer: "
+                + "CryptoException.ERROR_INSUFFICIENT_OUTPUT_PROTECTION");
+        // Note that in Android OS version before 23, the MediaDrm class doesn't expose the current
+        // key ids it holds.  In such case the Starboard media stack is unable to notify Cobalt of
+        // the error via key statuses so MEDIA_CODEC_ERROR is returned instead to signal a general
+        // media codec error.
+        return Build.VERSION.SDK_INT >= 23
+            ? MEDIA_CODEC_INSUFFICIENT_OUTPUT_PROTECTION
+            : MEDIA_CODEC_ERROR;
+      }
+      Log.e(
+          TAG,
+          "Failed to queue secure input buffer, CryptoException with error code "
+              + e.getErrorCode());
+      return MEDIA_CODEC_ERROR;
+    } catch (IllegalStateException e) {
+      Log.e(TAG, "Failed to queue secure input buffer, IllegalStateException " + e);
+      return MEDIA_CODEC_ERROR;
+    }
+    return MEDIA_CODEC_OK;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private void releaseOutputBuffer(int index, boolean render) {
+    try {
+      mMediaCodec.releaseOutputBuffer(index, render);
+    } catch (IllegalStateException e) {
+      // TODO: May need to report the error to the caller. crbug.com/356498.
+      Log.e(TAG, "Failed to release output buffer", e);
+    }
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private void releaseOutputBuffer(int index, long renderTimestampNs) {
+    try {
+      mMediaCodec.releaseOutputBuffer(index, renderTimestampNs);
+    } catch (IllegalStateException e) {
+      // TODO: May need to report the error to the caller. crbug.com/356498.
+      Log.e(TAG, "Failed to release output buffer", e);
+    }
+  }
+
+  @SuppressWarnings({"unused", "deprecation"})
+  @UsedByNative
+  private void dequeueOutputBuffer(long timeoutUs, DequeueOutputResult outDequeueOutputResult) {
+    int status = MEDIA_CODEC_ERROR;
+    int index = -1;
+    try {
+      int indexOrStatus = mMediaCodec.dequeueOutputBuffer(info, timeoutUs);
+      if (info.presentationTimeUs < mLastPresentationTimeUs) {
+        // TODO: return a special code through DequeueOutputResult
+        // to notify the native code that the frame has a wrong presentation
+        // timestamp and should be skipped.
+        info.presentationTimeUs = mLastPresentationTimeUs;
+      }
+      mLastPresentationTimeUs = info.presentationTimeUs;
+
+      if (indexOrStatus >= 0) { // index!
+        status = MEDIA_CODEC_OK;
+        index = indexOrStatus;
+      } else if (indexOrStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
+        status = MEDIA_CODEC_OUTPUT_BUFFERS_CHANGED;
+      } else if (indexOrStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+        status = MEDIA_CODEC_OUTPUT_FORMAT_CHANGED;
+        MediaFormat newFormat = mMediaCodec.getOutputFormat();
+      } else if (indexOrStatus == MediaCodec.INFO_TRY_AGAIN_LATER) {
+        status = MEDIA_CODEC_DEQUEUE_OUTPUT_AGAIN_LATER;
+      } else {
+        throw new AssertionError("Unexpected index_or_status: " + indexOrStatus);
+      }
+    } catch (IllegalStateException e) {
+      status = MEDIA_CODEC_ERROR;
+      Log.e(TAG, "Failed to dequeue output buffer", e);
+    }
+
+    outDequeueOutputResult.mStatus = status;
+    outDequeueOutputResult.mIndex = index;
+    outDequeueOutputResult.mFlags = info.flags;
+    outDequeueOutputResult.mOffset = info.offset;
+    outDequeueOutputResult.mPresentationTimeMicroseconds = info.presentationTimeUs;
+    outDequeueOutputResult.mNumBytes = info.size;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private boolean configureVideo(
+      MediaFormat format,
+      Surface surface,
+      MediaCrypto crypto,
+      int flags,
+      boolean allowAdaptivePlayback,
+      int maxSupportedWidth,
+      int maxSupportedHeight) {
+    try {
+      // If adaptive playback is turned off by request, then treat it as
+      // not supported.  Note that configureVideo is only called once
+      // during creation, else this would prevent re-enabling adaptive
+      // playback later.
+      if (!allowAdaptivePlayback) {
+        mAdaptivePlaybackSupported = false;
+      }
+
+      if (mAdaptivePlaybackSupported) {
+        // Since we haven't passed the properties of the stream we're playing
+        // down to this level, from our perspective, we could potentially
+        // adapt up to 4k at any point.  We thus request 4k buffers up front,
+        // unless the decoder claims to not be able to do 4k, in which case
+        // we're ok, since we would've rejected a 4k stream when canPlayType
+        // was called, and then use those decoder values instead.
+        int maxWidth = Math.min(3840, maxSupportedWidth);
+        int maxHeight = Math.min(2160, maxSupportedHeight);
+        format.setInteger(MediaFormat.KEY_MAX_WIDTH, maxWidth);
+        format.setInteger(MediaFormat.KEY_MAX_HEIGHT, maxHeight);
+      }
+      maybeSetMaxInputSize(format);
+      mMediaCodec.configure(format, surface, crypto, flags);
+      return true;
+    } catch (IllegalArgumentException e) {
+      Log.e(TAG, "Cannot configure the video codec, wrong format or surface", e);
+    } catch (IllegalStateException e) {
+      Log.e(TAG, "Cannot configure the video codec", e);
+    } catch (MediaCodec.CryptoException e) {
+      Log.e(TAG, "Cannot configure the video codec: DRM error", e);
+    } catch (Exception e) {
+      Log.e(TAG, "Cannot configure the video codec", e);
+    }
+    return false;
+  }
+
+  public static MediaFormat createAudioFormat(String mime, int sampleRate, int channelCount) {
+    return MediaFormat.createAudioFormat(mime, sampleRate, channelCount);
+  }
+
+  private static MediaFormat createVideoDecoderFormat(
+      String mime, int width, int height, VideoCapabilities videoCapabilities) {
+    return MediaFormat.createVideoFormat(
+        mime,
+        alignDimension(width, videoCapabilities.getWidthAlignment()),
+        alignDimension(height, videoCapabilities.getHeightAlignment()));
+  }
+
+  private static int alignDimension(int size, int alignment) {
+    int ceilDivide = (size + alignment - 1) / alignment;
+    return ceilDivide * alignment;
+  }
+
+  // Use some heuristics to set KEY_MAX_INPUT_SIZE (the size of the input buffers).
+  // Taken from exoplayer:
+  // https://github.com/google/ExoPlayer/blob/8595c65678a181296cdf673eacb93d8135479340/library/src/main/java/com/google/android/exoplayer/MediaCodecVideoTrackRenderer.java
+  private void maybeSetMaxInputSize(MediaFormat format) {
+    if (format.containsKey(android.media.MediaFormat.KEY_MAX_INPUT_SIZE)) {
+      // Already set. The source of the format may know better, so do nothing.
+      return;
+    }
+    int maxHeight = format.getInteger(MediaFormat.KEY_HEIGHT);
+    if (mAdaptivePlaybackSupported && format.containsKey(MediaFormat.KEY_MAX_HEIGHT)) {
+      maxHeight = Math.max(maxHeight, format.getInteger(MediaFormat.KEY_MAX_HEIGHT));
+    }
+    int maxWidth = format.getInteger(MediaFormat.KEY_WIDTH);
+    if (mAdaptivePlaybackSupported && format.containsKey(MediaFormat.KEY_MAX_WIDTH)) {
+      maxWidth = Math.max(maxHeight, format.getInteger(MediaFormat.KEY_MAX_WIDTH));
+    }
+    int maxPixels;
+    int minCompressionRatio;
+    switch (format.getString(MediaFormat.KEY_MIME)) {
+      case MimeTypes.VIDEO_H264:
+        if ("BRAVIA 4K 2015".equals(Build.MODEL)) {
+          // The Sony BRAVIA 4k TV has input buffers that are too small for the calculated
+          // 4k video maximum input size, so use the default value.
+          return;
+        }
+        // Round up width/height to an integer number of macroblocks.
+        maxPixels = ((maxWidth + 15) / 16) * ((maxHeight + 15) / 16) * 16 * 16;
+        minCompressionRatio = 2;
+        break;
+      case MimeTypes.VIDEO_VP8:
+        // VPX does not specify a ratio so use the values from the platform's SoftVPX.cpp.
+        maxPixels = maxWidth * maxHeight;
+        minCompressionRatio = 2;
+        break;
+      case MimeTypes.VIDEO_H265:
+      case MimeTypes.VIDEO_VP9:
+        maxPixels = maxWidth * maxHeight;
+        minCompressionRatio = 4;
+        break;
+      default:
+        // Leave the default max input size.
+        return;
+    }
+    // Estimate the maximum input size assuming three channel 4:2:0 subsampled input frames.
+    int maxInputSize = (maxPixels * 3) / (2 * minCompressionRatio);
+    format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, maxInputSize);
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private boolean isAdaptivePlaybackSupported(int width, int height) {
+    // If media codec has adaptive playback supported, then the max sizes
+    // used during creation are only hints.
+    return mAdaptivePlaybackSupported;
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private static void setCodecSpecificData(MediaFormat format, int index, byte[] bytes) {
+    // Codec Specific Data is set in the MediaFormat as ByteBuffer entries with keys csd-0,
+    // csd-1, and so on. See: http://developer.android.com/reference/android/media/MediaCodec.html
+    // for details.
+    String name;
+    switch (index) {
+      case 0:
+        name = "csd-0";
+        break;
+      case 1:
+        name = "csd-1";
+        break;
+      case 2:
+        name = "csd-2";
+        break;
+      default:
+        name = null;
+        break;
+    }
+    if (name != null) {
+      format.setByteBuffer(name, ByteBuffer.wrap(bytes));
+    }
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private static void setFrameHasADTSHeader(MediaFormat format) {
+    format.setInteger(MediaFormat.KEY_IS_ADTS, 1);
+  }
+
+  @SuppressWarnings("unused")
+  @UsedByNative
+  private boolean configureAudio(MediaFormat format, MediaCrypto crypto, int flags) {
+    try {
+      mMediaCodec.configure(format, null, crypto, flags);
+      return true;
+    } catch (IllegalArgumentException | IllegalStateException e) {
+      Log.e(TAG, "Cannot configure the audio codec", e);
+    } catch (MediaCodec.CryptoException e) {
+      Log.e(TAG, "Cannot configure the audio codec: DRM error", e);
+    } catch (Exception e) {
+      Log.e(TAG, "Cannot configure the audio codec", e);
+    }
+    return false;
+  }
+
+  private void resetLastPresentationTimeIfNeeded(long presentationTimeUs) {
+    if (mFlushed) {
+      mLastPresentationTimeUs =
+          Math.max(presentationTimeUs - MAX_PRESENTATION_TIMESTAMP_SHIFT_US, 0);
+      mFlushed = false;
+    }
+  }
+
+  @SuppressWarnings("deprecation")
+  private int getAudioFormat(int channelCount) {
+    switch (channelCount) {
+      case 1:
+        return AudioFormat.CHANNEL_OUT_MONO;
+      case 2:
+        return AudioFormat.CHANNEL_OUT_STEREO;
+      case 4:
+        return AudioFormat.CHANNEL_OUT_QUAD;
+      case 6:
+        return AudioFormat.CHANNEL_OUT_5POINT1;
+      case 8:
+        if (Build.VERSION.SDK_INT >= 23) {
+          return AudioFormat.CHANNEL_OUT_7POINT1_SURROUND;
+        } else {
+          return AudioFormat.CHANNEL_OUT_7POINT1;
+        }
+      default:
+        return AudioFormat.CHANNEL_OUT_DEFAULT;
+    }
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/MediaCodecUtil.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/MediaCodecUtil.java
new file mode 100644
index 0000000..25a13c2
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/MediaCodecUtil.java
@@ -0,0 +1,658 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.media;
+
+import static dev.cobalt.media.Log.TAG;
+
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecInfo.AudioCapabilities;
+import android.media.MediaCodecInfo.CodecCapabilities;
+import android.media.MediaCodecInfo.CodecProfileLevel;
+import android.media.MediaCodecInfo.VideoCapabilities;
+import android.media.MediaCodecList;
+import android.os.Build;
+import dev.cobalt.util.IsEmulator;
+import dev.cobalt.util.Log;
+import dev.cobalt.util.UsedByNative;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/** Utility functions for dealing with MediaCodec related things. */
+public class MediaCodecUtil {
+  // A low priority black list of codec names that should never be used.
+  private static final Set<String> codecBlackList = new HashSet<>();
+  // A high priority white list of brands/model that should always attempt to
+  // play vp9.
+  private static final Map<String, Set<String>> vp9WhiteList = new HashMap<>();
+  // Whether we should report vp9 codecs as supported or not.  Will be set
+  // based on whether vp9WhiteList contains our brand/model.  If this is set
+  // to true, then codecBlackList will be ignored.
+  private static boolean isVp9WhiteListed;
+  private static final String SECURE_DECODER_SUFFIX = ".secure";
+  private static final String VP9_MIME_TYPE = "video/x-vnd.on2.vp9";
+
+  /**
+   * A simple "struct" to bundle up the results from findVideoDecoder, as its clients may require
+   * the max supported width and height in addition to just the decoder name.
+   */
+  public static final class FindVideoDecoderResult {
+    public String name;
+    public VideoCapabilities videoCapabilities;
+    public CodecCapabilities codecCapabilities;
+
+    public FindVideoDecoderResult(
+        String name, VideoCapabilities videoCapabilities, CodecCapabilities codecCapabilities) {
+      this.name = name;
+      this.videoCapabilities = videoCapabilities;
+      this.codecCapabilities = codecCapabilities;
+    }
+  }
+
+  static {
+    if (Build.VERSION.SDK_INT >= 24 && Build.BRAND.equals("google")) {
+      codecBlackList.add("OMX.Nvidia.vp9.decode");
+    }
+    if (Build.VERSION.SDK_INT >= 24 && Build.BRAND.equals("LGE")) {
+      codecBlackList.add("OMX.qcom.video.decoder.vp9");
+    }
+    if (Build.VERSION.RELEASE.startsWith("6.0.1")) {
+      codecBlackList.add("OMX.Exynos.vp9.dec");
+      codecBlackList.add("OMX.Intel.VideoDecoder.VP9.hwr");
+      codecBlackList.add("OMX.MTK.VIDEO.DECODER.VP9");
+      codecBlackList.add("OMX.qcom.video.decoder.vp9");
+    }
+    if (Build.VERSION.RELEASE.startsWith("6.0")) {
+      codecBlackList.add("OMX.MTK.VIDEO.DECODER.VP9");
+      codecBlackList.add("OMX.Nvidia.vp9.decode");
+    }
+    if (Build.VERSION.RELEASE.startsWith("5.1.1")) {
+      codecBlackList.add("OMX.allwinner.video.decoder.vp9");
+      codecBlackList.add("OMX.Exynos.vp9.dec");
+      codecBlackList.add("OMX.Intel.VideoDecoder.VP9.hwr");
+      codecBlackList.add("OMX.MTK.VIDEO.DECODER.VP9");
+      codecBlackList.add("OMX.qcom.video.decoder.vp9");
+    }
+    if (Build.VERSION.RELEASE.startsWith("5.1")) {
+      codecBlackList.add("OMX.Exynos.VP9.Decoder");
+      codecBlackList.add("OMX.Intel.VideoDecoder.VP9.hwr");
+      codecBlackList.add("OMX.MTK.VIDEO.DECODER.VP9");
+    }
+    if (Build.VERSION.RELEASE.startsWith("5.0")) {
+      codecBlackList.add("OMX.allwinner.video.decoder.vp9");
+      codecBlackList.add("OMX.Exynos.vp9.dec");
+      codecBlackList.add("OMX.Intel.VideoDecoder.VP9.hwr");
+      codecBlackList.add("OMX.MTK.VIDEO.DECODER.VP9");
+    }
+
+    if (Build.BRAND.equals("google")) {
+      codecBlackList.add("OMX.Intel.VideoDecoder.VP9.hybrid");
+    }
+
+    // Black list non hardware media codec names if we aren't running on an emulator.
+    if (!IsEmulator.isEmulator()) {
+      codecBlackList.add("OMX.ffmpeg.vp9.decoder");
+      codecBlackList.add("OMX.Intel.sw_vd.vp9");
+      codecBlackList.add("OMX.MTK.VIDEO.DECODER.SW.VP9");
+    }
+
+    // Black list the Google software vp9 decoder both on hardware and on the emulator.
+    // On the emulator it fails with the log: "storeMetaDataInBuffers failed w/ err -1010"
+    codecBlackList.add("OMX.google.vp9.decoder");
+
+    vp9WhiteList.put("Amlogic", new HashSet<String>());
+    vp9WhiteList.put("Arcadyan", new HashSet<String>());
+    vp9WhiteList.put("arcelik", new HashSet<String>());
+    vp9WhiteList.put("BNO", new HashSet<String>());
+    vp9WhiteList.put("BROADCOM", new HashSet<String>());
+    vp9WhiteList.put("broadcom", new HashSet<String>());
+    vp9WhiteList.put("Foxconn", new HashSet<String>());
+    vp9WhiteList.put("Freebox", new HashSet<String>());
+    vp9WhiteList.put("gfiber", new HashSet<String>());
+    vp9WhiteList.put("Google", new HashSet<String>());
+    vp9WhiteList.put("google", new HashSet<String>());
+    vp9WhiteList.put("Hisense", new HashSet<String>());
+    vp9WhiteList.put("HUAWEI", new HashSet<String>());
+    vp9WhiteList.put("KaonMedia", new HashSet<String>());
+    vp9WhiteList.put("LeTV", new HashSet<String>());
+    vp9WhiteList.put("LGE", new HashSet<String>());
+    vp9WhiteList.put("MediaTek", new HashSet<String>());
+    vp9WhiteList.put("MStar", new HashSet<String>());
+    vp9WhiteList.put("MTK", new HashSet<String>());
+    vp9WhiteList.put("NVIDIA", new HashSet<String>());
+    vp9WhiteList.put("PHILIPS", new HashSet<String>());
+    vp9WhiteList.put("Philips", new HashSet<String>());
+    vp9WhiteList.put("PIXELA CORPORATION", new HashSet<String>());
+    vp9WhiteList.put("RCA", new HashSet<String>());
+    vp9WhiteList.put("Sagemcom", new HashSet<String>());
+    vp9WhiteList.put("samsung", new HashSet<String>());
+    vp9WhiteList.put("SHARP", new HashSet<String>());
+    vp9WhiteList.put("Skyworth", new HashSet<String>());
+    vp9WhiteList.put("Sony", new HashSet<String>());
+    vp9WhiteList.put("STMicroelectronics", new HashSet<String>());
+    vp9WhiteList.put("SumitomoElectricIndustries", new HashSet<String>());
+    vp9WhiteList.put("TCL", new HashSet<String>());
+    vp9WhiteList.put("Technicolor", new HashSet<String>());
+    vp9WhiteList.put("Vestel", new HashSet<String>());
+    vp9WhiteList.put("wnc", new HashSet<String>());
+    vp9WhiteList.put("Xiaomi", new HashSet<String>());
+    vp9WhiteList.put("ZTE TV", new HashSet<String>());
+
+    vp9WhiteList.get("Amlogic").add("p212");
+    vp9WhiteList.get("Arcadyan").add("Bouygtel4K");
+    vp9WhiteList.get("Arcadyan").add("HMB2213PW22TS");
+    vp9WhiteList.get("Arcadyan").add("IPSetTopBox");
+    vp9WhiteList.get("arcelik").add("arcelik_uhd_powermax_at");
+    vp9WhiteList.get("BNO").add("QM153E");
+    vp9WhiteList.get("broadcom").add("avko");
+    vp9WhiteList.get("broadcom").add("banff");
+    vp9WhiteList.get("BROADCOM").add("BCM7XXX_TEST_SETTOP");
+    vp9WhiteList.get("broadcom").add("cypress");
+    vp9WhiteList.get("broadcom").add("dawson");
+    vp9WhiteList.get("broadcom").add("elfin");
+    vp9WhiteList.get("Foxconn").add("ba101");
+    vp9WhiteList.get("Foxconn").add("bd201");
+    vp9WhiteList.get("Freebox").add("Freebox Player Mini v2");
+    vp9WhiteList.get("gfiber").add("GFHD254");
+    vp9WhiteList.get("google").add("avko");
+    vp9WhiteList.get("google").add("marlin");
+    vp9WhiteList.get("Google").add("Pixel XL");
+    vp9WhiteList.get("Google").add("Pixel");
+    vp9WhiteList.get("google").add("sailfish");
+    vp9WhiteList.get("google").add("sprint");
+    vp9WhiteList.get("Hisense").add("HAT4KDTV");
+    vp9WhiteList.get("HUAWEI").add("X21");
+    vp9WhiteList.get("KaonMedia").add("IC1110");
+    vp9WhiteList.get("KaonMedia").add("IC1130");
+    vp9WhiteList.get("KaonMedia").add("MCM4000");
+    vp9WhiteList.get("KaonMedia").add("PRDMK100T");
+    vp9WhiteList.get("KaonMedia").add("SFCSTB2LITE");
+    vp9WhiteList.get("LeTV").add("uMax85");
+    vp9WhiteList.get("LeTV").add("X4-43Pro");
+    vp9WhiteList.get("LeTV").add("X4-55");
+    vp9WhiteList.get("LeTV").add("X4-65");
+    vp9WhiteList.get("LGE").add("S60CLI");
+    vp9WhiteList.get("LGE").add("S60UPA");
+    vp9WhiteList.get("LGE").add("S60UPI");
+    vp9WhiteList.get("LGE").add("S70CDS");
+    vp9WhiteList.get("LGE").add("S70PCI");
+    vp9WhiteList.get("LGE").add("SH960C-DS");
+    vp9WhiteList.get("LGE").add("SH960C-LN");
+    vp9WhiteList.get("LGE").add("SH960S-AT");
+    vp9WhiteList.get("MediaTek").add("Archer");
+    vp9WhiteList.get("MediaTek").add("augie");
+    vp9WhiteList.get("MediaTek").add("kane");
+    vp9WhiteList.get("MStar").add("Denali");
+    vp9WhiteList.get("MStar").add("Rainier");
+    vp9WhiteList.get("MTK").add("Generic Android on sharp_2k15_us_android");
+    vp9WhiteList.get("NVIDIA").add("SHIELD Android TV");
+    vp9WhiteList.get("NVIDIA").add("SHIELD Console");
+    vp9WhiteList.get("NVIDIA").add("SHIELD Portable");
+    vp9WhiteList.get("PHILIPS").add("QM151E");
+    vp9WhiteList.get("PHILIPS").add("QM161E");
+    vp9WhiteList.get("PHILIPS").add("QM163E");
+    vp9WhiteList.get("Philips").add("TPM171E");
+    vp9WhiteList.get("PIXELA CORPORATION").add("POE-MP4000");
+    vp9WhiteList.get("RCA").add("XLDRCAV1");
+    vp9WhiteList.get("Sagemcom").add("DNA Android TV");
+    vp9WhiteList.get("Sagemcom").add("GigaTV");
+    vp9WhiteList.get("Sagemcom").add("M387_QL");
+    vp9WhiteList.get("Sagemcom").add("Sagemcom Android STB");
+    vp9WhiteList.get("Sagemcom").add("Sagemcom ATV Demo");
+    vp9WhiteList.get("Sagemcom").add("Telecable ATV");
+    vp9WhiteList.get("samsung").add("c71kw200");
+    vp9WhiteList.get("samsung").add("GX-CJ680CL");
+    vp9WhiteList.get("samsung").add("SAMSUNG-SM-G890A");
+    vp9WhiteList.get("samsung").add("SAMSUNG-SM-G920A");
+    vp9WhiteList.get("samsung").add("SAMSUNG-SM-G920AZ");
+    vp9WhiteList.get("samsung").add("SAMSUNG-SM-G925A");
+    vp9WhiteList.get("samsung").add("SAMSUNG-SM-G928A");
+    vp9WhiteList.get("samsung").add("SM-G9200");
+    vp9WhiteList.get("samsung").add("SM-G9208");
+    vp9WhiteList.get("samsung").add("SM-G9209");
+    vp9WhiteList.get("samsung").add("SM-G920A");
+    vp9WhiteList.get("samsung").add("SM-G920D");
+    vp9WhiteList.get("samsung").add("SM-G920F");
+    vp9WhiteList.get("samsung").add("SM-G920FD");
+    vp9WhiteList.get("samsung").add("SM-G920FQ");
+    vp9WhiteList.get("samsung").add("SM-G920I");
+    vp9WhiteList.get("samsung").add("SM-G920K");
+    vp9WhiteList.get("samsung").add("SM-G920L");
+    vp9WhiteList.get("samsung").add("SM-G920P");
+    vp9WhiteList.get("samsung").add("SM-G920R4");
+    vp9WhiteList.get("samsung").add("SM-G920R6");
+    vp9WhiteList.get("samsung").add("SM-G920R7");
+    vp9WhiteList.get("samsung").add("SM-G920S");
+    vp9WhiteList.get("samsung").add("SM-G920T");
+    vp9WhiteList.get("samsung").add("SM-G920T1");
+    vp9WhiteList.get("samsung").add("SM-G920V");
+    vp9WhiteList.get("samsung").add("SM-G920W8");
+    vp9WhiteList.get("samsung").add("SM-G9250");
+    vp9WhiteList.get("samsung").add("SM-G925A");
+    vp9WhiteList.get("samsung").add("SM-G925D");
+    vp9WhiteList.get("samsung").add("SM-G925F");
+    vp9WhiteList.get("samsung").add("SM-G925FQ");
+    vp9WhiteList.get("samsung").add("SM-G925I");
+    vp9WhiteList.get("samsung").add("SM-G925J");
+    vp9WhiteList.get("samsung").add("SM-G925K");
+    vp9WhiteList.get("samsung").add("SM-G925L");
+    vp9WhiteList.get("samsung").add("SM-G925P");
+    vp9WhiteList.get("samsung").add("SM-G925R4");
+    vp9WhiteList.get("samsung").add("SM-G925R6");
+    vp9WhiteList.get("samsung").add("SM-G925R7");
+    vp9WhiteList.get("samsung").add("SM-G925S");
+    vp9WhiteList.get("samsung").add("SM-G925T");
+    vp9WhiteList.get("samsung").add("SM-G925V");
+    vp9WhiteList.get("samsung").add("SM-G925W8");
+    vp9WhiteList.get("samsung").add("SM-G925Z");
+    vp9WhiteList.get("samsung").add("SM-G9280");
+    vp9WhiteList.get("samsung").add("SM-G9287");
+    vp9WhiteList.get("samsung").add("SM-G9287C");
+    vp9WhiteList.get("samsung").add("SM-G928A");
+    vp9WhiteList.get("samsung").add("SM-G928C");
+    vp9WhiteList.get("samsung").add("SM-G928F");
+    vp9WhiteList.get("samsung").add("SM-G928G");
+    vp9WhiteList.get("samsung").add("SM-G928I");
+    vp9WhiteList.get("samsung").add("SM-G928K");
+    vp9WhiteList.get("samsung").add("SM-G928L");
+    vp9WhiteList.get("samsung").add("SM-G928N0");
+    vp9WhiteList.get("samsung").add("SM-G928P");
+    vp9WhiteList.get("samsung").add("SM-G928S");
+    vp9WhiteList.get("samsung").add("SM-G928T");
+    vp9WhiteList.get("samsung").add("SM-G928V");
+    vp9WhiteList.get("samsung").add("SM-G928W8");
+    vp9WhiteList.get("samsung").add("SM-G928X");
+    vp9WhiteList.get("samsung").add("SM-G9300");
+    vp9WhiteList.get("samsung").add("SM-G9308");
+    vp9WhiteList.get("samsung").add("SM-G930A");
+    vp9WhiteList.get("samsung").add("SM-G930AZ");
+    vp9WhiteList.get("samsung").add("SM-G930F");
+    vp9WhiteList.get("samsung").add("SM-G930FD");
+    vp9WhiteList.get("samsung").add("SM-G930K");
+    vp9WhiteList.get("samsung").add("SM-G930L");
+    vp9WhiteList.get("samsung").add("SM-G930P");
+    vp9WhiteList.get("samsung").add("SM-G930R4");
+    vp9WhiteList.get("samsung").add("SM-G930R6");
+    vp9WhiteList.get("samsung").add("SM-G930R7");
+    vp9WhiteList.get("samsung").add("SM-G930S");
+    vp9WhiteList.get("samsung").add("SM-G930T");
+    vp9WhiteList.get("samsung").add("SM-G930T1");
+    vp9WhiteList.get("samsung").add("SM-G930U");
+    vp9WhiteList.get("samsung").add("SM-G930V");
+    vp9WhiteList.get("samsung").add("SM-G930VL");
+    vp9WhiteList.get("samsung").add("SM-G930W8");
+    vp9WhiteList.get("samsung").add("SM-G9350");
+    vp9WhiteList.get("samsung").add("SM-G935A");
+    vp9WhiteList.get("samsung").add("SM-G935D");
+    vp9WhiteList.get("samsung").add("SM-G935F");
+    vp9WhiteList.get("samsung").add("SM-G935FD");
+    vp9WhiteList.get("samsung").add("SM-G935J");
+    vp9WhiteList.get("samsung").add("SM-G935K");
+    vp9WhiteList.get("samsung").add("SM-G935L");
+    vp9WhiteList.get("samsung").add("SM-G935P");
+    vp9WhiteList.get("samsung").add("SM-G935R4");
+    vp9WhiteList.get("samsung").add("SM-G935S");
+    vp9WhiteList.get("samsung").add("SM-G935T");
+    vp9WhiteList.get("samsung").add("SM-G935U");
+    vp9WhiteList.get("samsung").add("SM-G935V");
+    vp9WhiteList.get("samsung").add("SM-G935W8");
+    vp9WhiteList.get("samsung").add("SM-N9200");
+    vp9WhiteList.get("samsung").add("SM-N9208");
+    vp9WhiteList.get("samsung").add("SM-N920A");
+    vp9WhiteList.get("samsung").add("SM-N920C");
+    vp9WhiteList.get("samsung").add("SM-N920F");
+    vp9WhiteList.get("samsung").add("SM-N920G");
+    vp9WhiteList.get("samsung").add("SM-N920I");
+    vp9WhiteList.get("samsung").add("SM-N920K");
+    vp9WhiteList.get("samsung").add("SM-N920L");
+    vp9WhiteList.get("samsung").add("SM-N920R4");
+    vp9WhiteList.get("samsung").add("SM-N920R6");
+    vp9WhiteList.get("samsung").add("SM-N920R7");
+    vp9WhiteList.get("samsung").add("SM-N920S");
+    vp9WhiteList.get("samsung").add("SM-N920T");
+    vp9WhiteList.get("samsung").add("SM-N920TP");
+    vp9WhiteList.get("samsung").add("SM-N920V");
+    vp9WhiteList.get("samsung").add("SM-N920W8");
+    vp9WhiteList.get("samsung").add("SM-N920X");
+    vp9WhiteList.get("SHARP").add("AN-NP40");
+    vp9WhiteList.get("SHARP").add("AQUOS-4KTVJ17");
+    vp9WhiteList.get("SHARP").add("AQUOS-4KTVT17");
+    vp9WhiteList.get("SHARP").add("AQUOS-4KTVX17");
+    vp9WhiteList.get("SHARP").add("LC-U35T");
+    vp9WhiteList.get("SHARP").add("LC-UE630X");
+    vp9WhiteList.get("SHARP").add("LC-Ux30US");
+    vp9WhiteList.get("SHARP").add("LC-XU35T");
+    vp9WhiteList.get("SHARP").add("LC-XU930X_830X");
+    vp9WhiteList.get("Skyworth").add("globe");
+    vp9WhiteList.get("Sony").add("Amai VP9");
+    vp9WhiteList.get("Sony").add("BRAVIA 4K 2015");
+    vp9WhiteList.get("Sony").add("BRAVIA 4K GB");
+    vp9WhiteList.get("STMicroelectronics").add("sti4k");
+    vp9WhiteList.get("SumitomoElectricIndustries").add("C02AS");
+    vp9WhiteList.get("SumitomoElectricIndustries").add("ST4173");
+    vp9WhiteList.get("SumitomoElectricIndustries").add("test_STW2000");
+    vp9WhiteList.get("TCL").add("Percee TV");
+    vp9WhiteList.get("Technicolor").add("AirTV Player");
+    vp9WhiteList.get("Technicolor").add("Bouygtel4K");
+    vp9WhiteList.get("Technicolor").add("CM-7600");
+    vp9WhiteList.get("Technicolor").add("cooper");
+    vp9WhiteList.get("Technicolor").add("Foxtel Now box");
+    vp9WhiteList.get("Technicolor").add("pearl");
+    vp9WhiteList.get("Technicolor").add("Sapphire");
+    vp9WhiteList.get("Technicolor").add("Shortcut");
+    vp9WhiteList.get("Technicolor").add("skipper");
+    vp9WhiteList.get("Technicolor").add("STING");
+    vp9WhiteList.get("Technicolor").add("TIM_BOX");
+    vp9WhiteList.get("Technicolor").add("uzx8020chm");
+    vp9WhiteList.get("Vestel").add("S7252");
+    vp9WhiteList.get("Vestel").add("SmartTV");
+    vp9WhiteList.get("wnc").add("c71kw400");
+    vp9WhiteList.get("Xiaomi").add("MIBOX3");
+    vp9WhiteList.get("ZTE TV").add("AV-ATB100");
+    vp9WhiteList.get("ZTE TV").add("B860H");
+
+    isVp9WhiteListed =
+        vp9WhiteList.containsKey(Build.BRAND)
+            && vp9WhiteList.get(Build.BRAND).contains(Build.MODEL);
+  }
+
+  private MediaCodecUtil() {}
+
+  /**
+   * Returns whether a given combination of (frame width x frame height) frames at bitrate and fps
+   * has a decoder with mime type.
+   *
+   * <p>Setting any of the int parameters to 0 indicates that they shouldn't be considered.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public static boolean hasVideoDecoderFor(
+      String mimeType,
+      boolean secure,
+      int frameWidth,
+      int frameHeight,
+      int bitrate,
+      int fps,
+      boolean mustSupportHdr) {
+    FindVideoDecoderResult findVideoDecoderResult =
+        findVideoDecoder(mimeType, secure, frameWidth, frameHeight, bitrate, fps, mustSupportHdr);
+    return !findVideoDecoderResult.name.equals("")
+        && (!mustSupportHdr || isHdrCapableVp9Decoder(findVideoDecoderResult));
+  }
+
+  /**
+   * Returns whether an audio decoder that supports mimeType at bitrate. Setting bitrate to 0
+   * indicates that it should not be considered.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public static boolean hasAudioDecoderFor(String mimeType, int bitrate) {
+    return !findAudioDecoder(mimeType, bitrate).equals("");
+  }
+
+  /** Determine whether the system has a decoder capable of playing HDR VP9. */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public static boolean hasHdrCapableVp9Decoder() {
+    // VP9Profile* values were not added until API level 24.  See
+    // https://developer.android.com/reference/android/media/MediaCodecInfo.CodecProfileLevel.html.
+    if (Build.VERSION.SDK_INT < 24) {
+      return false;
+    }
+
+    FindVideoDecoderResult findVideoDecoderResult =
+        findVideoDecoder(VP9_MIME_TYPE, false, 0, 0, 0, 0, true);
+    return isHdrCapableVp9Decoder(findVideoDecoderResult);
+  }
+
+  /** Determine whether findVideoDecoderResult is capable of playing HDR VP9 */
+  public static boolean isHdrCapableVp9Decoder(FindVideoDecoderResult findVideoDecoderResult) {
+    CodecCapabilities codecCapabilities = findVideoDecoderResult.codecCapabilities;
+    if (codecCapabilities == null) {
+      return false;
+    }
+    CodecProfileLevel[] codecProfileLevels = codecCapabilities.profileLevels;
+    if (codecProfileLevels == null) {
+      return false;
+    }
+    for (CodecProfileLevel codecProfileLevel : codecProfileLevels) {
+      if (codecProfileLevel.profile == CodecProfileLevel.VP9Profile2HDR
+          || codecProfileLevel.profile == CodecProfileLevel.VP9Profile3HDR) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  /**
+   * The same as hasVideoDecoderFor, only return the name of the video decoder if it is found, and
+   * "" otherwise.
+   */
+  public static FindVideoDecoderResult findVideoDecoder(
+      String mimeType,
+      boolean secure,
+      int frameWidth,
+      int frameHeight,
+      int bitrate,
+      int fps,
+      boolean hdr) {
+    Log.v(
+        TAG,
+        String.format(
+            "Searching for video decoder with parameters "
+                + "mimeType: %s, secure: %b, frameWidth: %d, frameHeight: %d, bitrate: %d, fps: %d",
+            mimeType, secure, frameWidth, frameHeight, bitrate, fps));
+    Log.v(
+        TAG,
+        String.format(
+            "brand: %s, model: %s, version: %s, API level: %d, isVp9WhiteListed: %b",
+            Build.BRAND,
+            Build.MODEL,
+            Build.VERSION.RELEASE,
+            Build.VERSION.SDK_INT,
+            isVp9WhiteListed));
+
+    // Note: MediaCodecList is sorted by the framework such that the best decoders come first.
+    for (MediaCodecInfo info : new MediaCodecList(MediaCodecList.ALL_CODECS).getCodecInfos()) {
+      if (info.isEncoder()) {
+        continue;
+      }
+      for (String supportedType : info.getSupportedTypes()) {
+        if (!supportedType.equalsIgnoreCase(mimeType)) {
+          continue;
+        }
+
+        String name = info.getName();
+        if (!isVp9WhiteListed && codecBlackList.contains(name)) {
+          Log.v(TAG, String.format("Rejecting %s, reason: codec is black listed", name));
+          continue;
+        }
+        // MediaCodecList is supposed to feed us names of decoders that do NOT end in ".secure".  We
+        // are then supposed to check if FEATURE_SecurePlayback is supported, and it if is and we
+        // want a secure codec, we append ".secure" ourselves, and then pass that to
+        // MediaCodec.createDecoderByName.  Some devices, do not follow this spec, and show us
+        // decoders that end in ".secure".  Empirically, FEATURE_SecurePlayback has still been
+        // correct when this happens.
+        if (name.endsWith(SECURE_DECODER_SUFFIX)) {
+          // If we want a secure decoder, then make sure the version without ".secure" isn't
+          // blacklisted.
+          String nameWithoutSecureSuffix =
+              name.substring(0, name.length() - SECURE_DECODER_SUFFIX.length());
+          if (secure && !isVp9WhiteListed && codecBlackList.contains(nameWithoutSecureSuffix)) {
+            Log.v(
+                TAG,
+                String.format("Rejecting %s, reason: offpsec blacklisted secure decoder", name));
+            continue;
+          }
+          // If we don't want a secure decoder, then don't bother messing around with this thing.
+          if (!secure) {
+            Log.v(
+                TAG,
+                String.format(
+                    "Rejecting %s, reason: want !secure decoder and ends with .secure", name));
+            continue;
+          }
+        }
+
+        CodecCapabilities codecCapabilities = info.getCapabilitiesForType(supportedType);
+        if (secure
+            && !codecCapabilities.isFeatureSupported(
+                MediaCodecInfo.CodecCapabilities.FEATURE_SecurePlayback)) {
+          Log.v(
+              TAG,
+              String.format(
+                  "Rejecting %s, reason: want secure decoder and !FEATURE_SecurePlayback", name));
+          continue;
+        }
+
+        // VideoCapabilties is not implemented correctly on this device.
+        if (Build.VERSION.SDK_INT < 23
+            && Build.MODEL.equals("MIBOX3")
+            && name.equals("OMX.amlogic.vp9.decoder.awesome")
+            && (frameWidth > 1920 || frameHeight > 1080)) {
+          Log.v(TAG, "Skipping >1080p OMX.amlogic.vp9.decoder.awesome on mibox.");
+          continue;
+        }
+
+        VideoCapabilities videoCapabilities = codecCapabilities.getVideoCapabilities();
+        if (frameWidth != 0 && !videoCapabilities.getSupportedWidths().contains(frameWidth)) {
+          Log.v(
+              TAG,
+              String.format(
+                  "Rejecting %s, reason: supported widths %s does not contain %d",
+                  name, videoCapabilities.getSupportedWidths().toString(), frameWidth));
+          continue;
+        }
+        if (frameHeight != 0 && !videoCapabilities.getSupportedHeights().contains(frameHeight)) {
+          Log.v(
+              TAG,
+              String.format(
+                  "Rejecting %s, reason: supported heights %s does not contain %d",
+                  name, videoCapabilities.getSupportedHeights().toString(), frameHeight));
+          continue;
+        }
+        if (bitrate != 0 && !videoCapabilities.getBitrateRange().contains(bitrate)) {
+          Log.v(
+              TAG,
+              String.format(
+                  "Rejecting %s, reason: bitrate range %s does not contain %d",
+                  name, videoCapabilities.getBitrateRange().toString(), bitrate));
+          continue;
+        }
+        if (fps != 0 && !videoCapabilities.getSupportedFrameRates().contains(fps)) {
+          Log.v(
+              TAG,
+              String.format(
+                  "Rejecting %s, reason: supported frame rates %s does not contain %d",
+                  name, videoCapabilities.getSupportedFrameRates().toString(), fps));
+          continue;
+        }
+        String resultName =
+            (secure && !name.endsWith(SECURE_DECODER_SUFFIX))
+                ? (name + SECURE_DECODER_SUFFIX)
+                : name;
+        FindVideoDecoderResult findVideoDecoderResult =
+            new FindVideoDecoderResult(resultName, videoCapabilities, codecCapabilities);
+        if (hdr && !isHdrCapableVp9Decoder(findVideoDecoderResult)) {
+          continue;
+        }
+        Log.v(TAG, String.format("Found suitable decoder, %s", name));
+        return findVideoDecoderResult;
+      }
+    }
+    return new FindVideoDecoderResult("", null, null);
+  }
+
+  /**
+   * The same as hasAudioDecoderFor, only return the name of the audio decoder if it is found, and
+   * "" otherwise.
+   */
+  public static String findAudioDecoder(String mimeType, int bitrate) {
+    // Note: MediaCodecList is sorted by the framework such that the best decoders come first.
+    for (MediaCodecInfo info : new MediaCodecList(MediaCodecList.ALL_CODECS).getCodecInfos()) {
+      if (info.isEncoder()) {
+        continue;
+      }
+      for (String supportedType : info.getSupportedTypes()) {
+        if (!supportedType.equalsIgnoreCase(mimeType)) {
+          continue;
+        }
+        String name = info.getName();
+        AudioCapabilities audioCapabilities =
+            info.getCapabilitiesForType(supportedType).getAudioCapabilities();
+        if (bitrate != 0 && !audioCapabilities.getBitrateRange().contains(bitrate)) {
+          continue;
+        }
+        return name;
+      }
+    }
+    return "";
+  }
+
+  /**
+   * Debug utility function that can be locally added to dump information about all decoders on a
+   * particular system.
+   */
+  @SuppressWarnings("unused")
+  private static void dumpAllDecoders() {
+    for (MediaCodecInfo info : new MediaCodecList(MediaCodecList.ALL_CODECS).getCodecInfos()) {
+      if (info.isEncoder()) {
+        continue;
+      }
+      for (String supportedType : info.getSupportedTypes()) {
+        String name = info.getName();
+        CodecCapabilities codecCapabilities = info.getCapabilitiesForType(supportedType);
+        Log.v(TAG, "==================================================");
+        Log.v(TAG, String.format("name: %s", name));
+        Log.v(TAG, String.format("supportedType: %s", supportedType));
+        Log.v(
+            TAG, String.format("codecBlackList.contains(name): %b", codecBlackList.contains(name)));
+        Log.v(
+            TAG,
+            String.format(
+                "FEATURE_SecurePlayback: %b",
+                codecCapabilities.isFeatureSupported(
+                    MediaCodecInfo.CodecCapabilities.FEATURE_SecurePlayback)));
+        VideoCapabilities videoCapabilities = codecCapabilities.getVideoCapabilities();
+        if (videoCapabilities != null) {
+          Log.v(
+              TAG,
+              String.format(
+                  "videoCapabilities.getSupportedWidths(): %s",
+                  videoCapabilities.getSupportedWidths().toString()));
+          Log.v(
+              TAG,
+              String.format(
+                  "videoCapabilities.getSupportedHeights(): %s",
+                  videoCapabilities.getSupportedHeights().toString()));
+          Log.v(
+              TAG,
+              String.format(
+                  "videoCapabilities.getBitrateRange(): %s",
+                  videoCapabilities.getBitrateRange().toString()));
+          Log.v(
+              TAG,
+              String.format(
+                  "videoCapabilities.getSupportedFrameRates(): %s",
+                  videoCapabilities.getSupportedFrameRates().toString()));
+        }
+        Log.v(TAG, "==================================================");
+        Log.v(TAG, "");
+      }
+    }
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/MediaDrmBridge.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/MediaDrmBridge.java
new file mode 100644
index 0000000..4619b57
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/MediaDrmBridge.java
@@ -0,0 +1,661 @@
+// Copyright 2013 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// Modifications Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.media;
+
+import static dev.cobalt.media.Log.TAG;
+
+import android.annotation.TargetApi;
+import android.media.DeniedByServerException;
+import android.media.MediaCrypto;
+import android.media.MediaCryptoException;
+import android.media.MediaDrm;
+import android.media.MediaDrm.OnEventListener;
+import android.media.MediaDrmException;
+import android.media.NotProvisionedException;
+import android.media.UnsupportedSchemeException;
+import android.os.Build;
+import dev.cobalt.coat.CobaltHttpHelper;
+import dev.cobalt.util.Log;
+import dev.cobalt.util.UsedByNative;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.UUID;
+
+/** A wrapper of the android MediaDrm class. */
+@UsedByNative
+public class MediaDrmBridge {
+  // Implementation Notes:
+  // - A media crypto session (mMediaCryptoSession) is opened after MediaDrm
+  //   is created. This session will NOT be added to mSessionIds and will only
+  //   be used to create the MediaCrypto object.
+  // - Each createSession() call creates a new session. All created sessions
+  //   are managed in mSessionIds.
+  // - Whenever NotProvisionedException is thrown, we will clean up the
+  //   current state and start the provisioning process.
+  // - When provisioning is finished, we will try to resume suspended
+  //   operations:
+  //   a) Create the media crypto session if it's not created.
+  //   b) Finish createSession() if previous createSession() was interrupted
+  //      by a NotProvisionedException.
+  // - Whenever an unexpected error occurred, we'll call release() to release
+  //   all resources immediately, clear all states and fail all pending
+  //   operations. After that all calls to this object will fail (e.g. return
+  //   null or reject the promise). All public APIs and callbacks should check
+  //   mMediaBridge to make sure release() hasn't been called.
+
+  private static final char[] HEX_CHAR_LOOKUP = "0123456789ABCDEF".toCharArray();
+  private static final long INVALID_NATIVE_MEDIA_DRM_BRIDGE = 0;
+
+  // The value of this must stay in sync with kSbDrmTicketInvalid in "starboard/drm.h"
+  private static final int SB_DRM_TICKET_INVALID = Integer.MIN_VALUE;
+
+  // Scheme UUID for Widevine. See http://dashif.org/identifiers/protection/
+  private static final UUID WIDEVINE_UUID = UUID.fromString("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed");
+
+  // Deprecated in API 26, but we still log it on earlier devices.
+  // We do handle STATUS_EXPIRED in nativeOnKeyStatusChange() for API 23+ devices.
+  @SuppressWarnings("deprecation")
+  private static final int MEDIA_DRM_EVENT_KEY_EXPIRED = MediaDrm.EVENT_KEY_EXPIRED;
+
+  private MediaDrm mMediaDrm;
+  private long mNativeMediaDrmBridge;
+  private UUID mSchemeUUID;
+
+  // A session only for the purpose of creating a MediaCrypto object. Created
+  // after construction, or after the provisioning process is successfully
+  // completed. No getKeyRequest() should be called on |mMediaCryptoSession|.
+  private byte[] mMediaCryptoSession;
+
+  // The map of all opened sessions (excluding mMediaCryptoSession) to their
+  // mime types.
+  private HashMap<ByteBuffer, String> mSessionIds = new HashMap<>();
+
+  private MediaCrypto mMediaCrypto;
+
+  /**
+   * Create a new MediaDrmBridge with the Widevine crypto scheme.
+   *
+   * @param nativeMediaDrmBridge The native owner of this class.
+   */
+  @UsedByNative
+  static MediaDrmBridge create(long nativeMediaDrmBridge) {
+    UUID cryptoScheme = WIDEVINE_UUID;
+    if (!MediaDrm.isCryptoSchemeSupported(cryptoScheme)) {
+      return null;
+    }
+
+    MediaDrmBridge mediaDrmBridge = null;
+    try {
+      mediaDrmBridge = new MediaDrmBridge(cryptoScheme, nativeMediaDrmBridge);
+      Log.d(TAG, "MediaDrmBridge successfully created.");
+    } catch (UnsupportedSchemeException e) {
+      Log.e(TAG, "Unsupported DRM scheme", e);
+      return null;
+    } catch (IllegalArgumentException e) {
+      Log.e(TAG, "Failed to create MediaDrmBridge", e);
+      return null;
+    } catch (IllegalStateException e) {
+      Log.e(TAG, "Failed to create MediaDrmBridge", e);
+      return null;
+    }
+
+    if (!mediaDrmBridge.createMediaCrypto()) {
+      return null;
+    }
+
+    return mediaDrmBridge;
+  }
+
+  /**
+   * Check whether the Widevine crypto scheme is supported.
+   *
+   * @return true if the container and the crypto scheme is supported, or false otherwise.
+   */
+  @UsedByNative
+  static boolean isWidevineCryptoSchemeSupported() {
+    return MediaDrm.isCryptoSchemeSupported(WIDEVINE_UUID);
+  }
+
+  /**
+   * Check whether the Widevine crypto scheme is supported for the given container. If
+   * |containerMimeType| is an empty string, we just return whether the crypto scheme is supported.
+   *
+   * @return true if the container and the crypto scheme is supported, or false otherwise.
+   */
+  @UsedByNative
+  static boolean isWidevineCryptoSchemeSupported(String containerMimeType) {
+    if (containerMimeType.isEmpty()) {
+      return isWidevineCryptoSchemeSupported();
+    }
+    return MediaDrm.isCryptoSchemeSupported(WIDEVINE_UUID, containerMimeType);
+  }
+
+  /** Destroy the MediaDrmBridge object. */
+  @UsedByNative
+  void destroy() {
+    mNativeMediaDrmBridge = INVALID_NATIVE_MEDIA_DRM_BRIDGE;
+    if (mMediaDrm != null) {
+      release();
+    }
+  }
+
+  @UsedByNative
+  void createSession(int ticket, byte[] initData, String mime) {
+    Log.d(TAG, "createSession()");
+
+    if (mMediaDrm == null) {
+      Log.e(TAG, "createSession() called when MediaDrm is null.");
+      return;
+    }
+
+    boolean newSessionOpened = false;
+    byte[] sessionId = null;
+    try {
+      sessionId = openSession();
+      if (sessionId == null) {
+        Log.e(TAG, "Open session failed.");
+        return;
+      }
+      newSessionOpened = true;
+      if (sessionExists(sessionId)) {
+        Log.e(TAG, "Opened session that already exists.");
+        return;
+      }
+
+      MediaDrm.KeyRequest request = null;
+      request = getKeyRequest(sessionId, initData, mime);
+      if (request == null) {
+        try {
+          // Some implementations let this method throw exceptions.
+          mMediaDrm.closeSession(sessionId);
+        } catch (Exception e) {
+          Log.e(TAG, "closeSession failed", e);
+        }
+        Log.e(TAG, "Generate request failed.");
+        return;
+      }
+
+      // Success!
+      Log.d(
+          TAG,
+          String.format("createSession(): Session (%s) created.", bytesToHexString(sessionId)));
+      mSessionIds.put(ByteBuffer.wrap(sessionId), mime);
+      onSessionMessage(ticket, sessionId, request);
+    } catch (NotProvisionedException e) {
+      Log.e(TAG, "Device not provisioned", e);
+      if (newSessionOpened) {
+        try {
+          // Some implementations let this method throw exceptions.
+          mMediaDrm.closeSession(sessionId);
+        } catch (Exception ex) {
+          Log.e(TAG, "closeSession failed", ex);
+        }
+      }
+      attemptProvisioning();
+    }
+  }
+
+  /**
+   * Update a session with response.
+   *
+   * @param sessionId Reference ID of session to be updated.
+   * @param response Response data from the server.
+   */
+  @UsedByNative
+  boolean updateSession(byte[] sessionId, byte[] response) {
+    Log.d(TAG, "updateSession()");
+    if (mMediaDrm == null) {
+      Log.e(TAG, "updateSession() called when MediaDrm is null.");
+      return false;
+    }
+
+    if (!sessionExists(sessionId)) {
+      Log.e(TAG, "updateSession tried to update a session that does not exist.");
+      return false;
+    }
+
+    try {
+      try {
+        mMediaDrm.provideKeyResponse(sessionId, response);
+      } catch (IllegalStateException e) {
+        // This is not really an exception. Some error codes are incorrectly
+        // reported as an exception.
+        Log.e(TAG, "Exception intentionally caught when calling provideKeyResponse()", e);
+      }
+      Log.d(
+          TAG, String.format("Key successfully added for session %s", bytesToHexString(sessionId)));
+      if (Build.VERSION.SDK_INT < 23) {
+        // Pass null to indicate that KeyStatus isn't supported.
+        nativeOnKeyStatusChange(mNativeMediaDrmBridge, sessionId, null);
+      }
+      return true;
+    } catch (NotProvisionedException e) {
+      // TODO: Should we handle this?
+      Log.e(TAG, "failed to provide key response", e);
+    } catch (DeniedByServerException e) {
+      Log.e(TAG, "failed to provide key response", e);
+    }
+    Log.e(TAG, "Update session failed.");
+    release();
+    return false;
+  }
+
+  /**
+   * Close a session that was previously created by createSession().
+   *
+   * @param sessionId ID of session to be closed.
+   */
+  @UsedByNative
+  void closeSession(byte[] sessionId) {
+    Log.d(TAG, "closeSession()");
+    if (mMediaDrm == null) {
+      Log.e(TAG, "closeSession() called when MediaDrm is null.");
+      return;
+    }
+
+    if (!sessionExists(sessionId)) {
+      Log.e(TAG, "Invalid sessionId in closeSession(): " + bytesToHexString(sessionId));
+      return;
+    }
+
+    try {
+      // Some implementations don't have removeKeys.
+      // https://bugs.chromium.org/p/chromium/issues/detail?id=475632
+      mMediaDrm.removeKeys(sessionId);
+    } catch (Exception e) {
+      Log.e(TAG, "removeKeys failed: ", e);
+    }
+    try {
+      // Some implementations let this method throw exceptions.
+      mMediaDrm.closeSession(sessionId);
+    } catch (Exception e) {
+      Log.e(TAG, "closeSession failed: ", e);
+    }
+    mSessionIds.remove(ByteBuffer.wrap(sessionId));
+    Log.d(TAG, String.format("Session %s closed", bytesToHexString(sessionId)));
+  }
+
+  @UsedByNative
+  MediaCrypto getMediaCrypto() {
+    return mMediaCrypto;
+  }
+
+  private MediaDrmBridge(UUID schemeUUID, long nativeMediaDrmBridge)
+      throws android.media.UnsupportedSchemeException {
+    mSchemeUUID = schemeUUID;
+    mMediaDrm = new MediaDrm(schemeUUID);
+
+    mNativeMediaDrmBridge = nativeMediaDrmBridge;
+    if (!isNativeMediaDrmBridgeValid()) {
+      throw new IllegalArgumentException(
+          String.format("Invalid nativeMediaDrmBridge value: |%d|.", nativeMediaDrmBridge));
+    }
+
+    mMediaDrm.setOnEventListener(
+        new OnEventListener() {
+          @Override
+          public void onEvent(MediaDrm md, byte[] sessionId, int event, int extra, byte[] data) {
+            if (sessionId == null) {
+              Log.e(TAG, "EventListener: Null session.");
+              return;
+            }
+            if (!sessionExists(sessionId)) {
+              Log.e(
+                  TAG,
+                  String.format("EventListener: Invalid session %s", bytesToHexString(sessionId)));
+              return;
+            }
+            switch (event) {
+              case MediaDrm.EVENT_KEY_REQUIRED:
+                Log.d(TAG, "MediaDrm.EVENT_KEY_REQUIRED");
+                String mime = mSessionIds.get(ByteBuffer.wrap(sessionId));
+                MediaDrm.KeyRequest request = null;
+                try {
+                  request = getKeyRequest(sessionId, data, mime);
+                } catch (NotProvisionedException e) {
+                  Log.e(TAG, "Device not provisioned", e);
+                  if (!attemptProvisioning()) {
+                    Log.e(TAG, "Failed to provision device when responding to EVENT_KEY_REQUIRED");
+                    return;
+                  }
+                  // If we supposedly successfully provisioned ourselves, then try to create a
+                  // request again.
+                  try {
+                    request = getKeyRequest(sessionId, data, mime);
+                  } catch (NotProvisionedException e2) {
+                    Log.e(
+                        TAG,
+                        "Device still not provisioned after supposedly successful provisioning",
+                        e2);
+                    return;
+                  }
+                }
+                if (request != null) {
+                  onSessionMessage(SB_DRM_TICKET_INVALID, sessionId, request);
+                } else {
+                  Log.e(TAG, "EventListener: getKeyRequest failed.");
+                  return;
+                }
+                break;
+              case MEDIA_DRM_EVENT_KEY_EXPIRED:
+                Log.d(TAG, "MediaDrm.EVENT_KEY_EXPIRED");
+                break;
+              case MediaDrm.EVENT_VENDOR_DEFINED:
+                Log.d(TAG, "MediaDrm.EVENT_VENDOR_DEFINED");
+                break;
+              default:
+                Log.e(TAG, "Invalid DRM event " + event);
+                return;
+            }
+          }
+        });
+
+    if (Build.VERSION.SDK_INT >= 23) {
+      setOnKeyStatusChangeListenerV23();
+    }
+
+    mMediaDrm.setPropertyString("privacyMode", "enable");
+    mMediaDrm.setPropertyString("sessionSharing", "enable");
+  }
+
+  @TargetApi(23)
+  private void setOnKeyStatusChangeListenerV23() {
+    mMediaDrm.setOnKeyStatusChangeListener(
+        new MediaDrm.OnKeyStatusChangeListener() {
+          @Override
+          public void onKeyStatusChange(
+              MediaDrm md,
+              byte[] sessionId,
+              List<MediaDrm.KeyStatus> keyInformation,
+              boolean hasNewUsableKey) {
+            nativeOnKeyStatusChange(
+                mNativeMediaDrmBridge,
+                sessionId,
+                keyInformation.toArray(new MediaDrm.KeyStatus[keyInformation.size()]));
+          }
+        },
+        null);
+  }
+
+  /** Convert byte array to hex string for logging. */
+  private static String bytesToHexString(byte[] bytes) {
+    StringBuilder hexString = new StringBuilder();
+    for (int i = 0; i < bytes.length; ++i) {
+      hexString.append(HEX_CHAR_LOOKUP[bytes[i] >>> 4]);
+      hexString.append(HEX_CHAR_LOOKUP[bytes[i] & 0xf]);
+    }
+    return hexString.toString();
+  }
+
+  private void onSessionMessage(
+      int ticket, final byte[] sessionId, final MediaDrm.KeyRequest request) {
+    if (!isNativeMediaDrmBridgeValid()) {
+      return;
+    }
+
+    int requestType = MediaDrm.KeyRequest.REQUEST_TYPE_INITIAL;
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+      requestType = request.getRequestType();
+    } else {
+      // Prior to M, getRequestType() is not supported. Do our best guess here: Assume
+      // requests with a URL are renewals and all others are initial requests.
+      requestType =
+          request.getDefaultUrl().isEmpty()
+              ? MediaDrm.KeyRequest.REQUEST_TYPE_INITIAL
+              : MediaDrm.KeyRequest.REQUEST_TYPE_RENEWAL;
+    }
+
+    nativeOnSessionMessage(
+        mNativeMediaDrmBridge, ticket, sessionId, requestType, request.getData());
+  }
+
+  /**
+   * Get a key request.
+   *
+   * @param sessionId ID of session on which we need to get the key request.
+   * @param data Data needed to get the key request.
+   * @param mime Mime type to get the key request.
+   * @return the key request.
+   */
+  private MediaDrm.KeyRequest getKeyRequest(byte[] sessionId, byte[] data, String mime)
+      throws android.media.NotProvisionedException {
+    if (mMediaDrm == null) {
+      throw new IllegalStateException("mMediaDrm cannot be null in getKeyRequest");
+    }
+    if (mMediaCryptoSession == null) {
+      throw new IllegalStateException("mMediaCryptoSession cannot be null in getKeyRequest.");
+    }
+    // TODO: Cannot do this during provisioning pending.
+
+    HashMap<String, String> optionalParameters = new HashMap<>();
+    MediaDrm.KeyRequest request = null;
+    try {
+      request =
+          mMediaDrm.getKeyRequest(
+              sessionId, data, mime, MediaDrm.KEY_TYPE_STREAMING, optionalParameters);
+    } catch (IllegalStateException e) {
+      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
+          && e instanceof android.media.MediaDrm.MediaDrmStateException) {
+        Log.e(TAG, "MediaDrmStateException fired during getKeyRequest().", e);
+      }
+    }
+
+    String result = (request != null) ? "succeeded" : "failed";
+    Log.d(TAG, String.format("getKeyRequest %s!", result));
+
+    return request;
+  }
+
+  /**
+   * Create a MediaCrypto object.
+   *
+   * @return false upon fatal error in creating MediaCrypto. Returns true otherwise, including the
+   *     following two cases: 1. MediaCrypto is successfully created and notified. 2. Device is not
+   *     provisioned and MediaCrypto creation will be tried again after the provisioning process is
+   *     completed.
+   *     <p>When false is returned, the caller should call release(), which will notify the native
+   *     code with a null MediaCrypto, if needed.
+   */
+  private boolean createMediaCrypto() {
+    if (mMediaDrm == null) {
+      throw new IllegalStateException("Cannot create media crypto with null mMediaDrm.");
+    }
+    if (mMediaCryptoSession != null) {
+      throw new IllegalStateException(
+          "Cannot create media crypto with non-null mMediaCryptoSession.");
+    }
+    // TODO: Cannot do this during provisioning pending.
+
+    // Open media crypto session.
+    try {
+      mMediaCryptoSession = openSession();
+    } catch (NotProvisionedException e) {
+      Log.d(TAG, "Device not provisioned", e);
+      if (!attemptProvisioning()) {
+        Log.e(TAG, "Failed to provision device during MediaCrypto creation.");
+        return false;
+      }
+      return true;
+    }
+
+    if (mMediaCryptoSession == null) {
+      Log.e(TAG, "Cannot create MediaCrypto Session.");
+      return false;
+    }
+    Log.d(
+        TAG,
+        String.format("MediaCrypto Session created: %s", bytesToHexString(mMediaCryptoSession)));
+
+    // Create MediaCrypto object.
+    try {
+      if (MediaCrypto.isCryptoSchemeSupported(mSchemeUUID)) {
+        MediaCrypto mediaCrypto = new MediaCrypto(mSchemeUUID, mMediaCryptoSession);
+        Log.d(TAG, "MediaCrypto successfully created!");
+        mMediaCrypto = mediaCrypto;
+        return true;
+      } else {
+        Log.e(TAG, "Cannot create MediaCrypto for unsupported scheme.");
+      }
+    } catch (MediaCryptoException e) {
+      Log.e(TAG, "Cannot create MediaCrypto", e);
+    }
+
+    try {
+      // Some implementations let this method throw exceptions.
+      mMediaDrm.closeSession(mMediaCryptoSession);
+    } catch (Exception e) {
+      Log.e(TAG, "closeSession failed: ", e);
+    }
+    mMediaCryptoSession = null;
+
+    return false;
+  }
+
+  /**
+   * Open a new session.
+   *
+   * @return ID of the session opened. Returns null if unexpected error happened.
+   */
+  private byte[] openSession() throws android.media.NotProvisionedException {
+    Log.d(TAG, "openSession()");
+    if (mMediaDrm == null) {
+      throw new IllegalStateException("mMediaDrm cannot be null in openSession");
+    }
+    try {
+      byte[] sessionId = mMediaDrm.openSession();
+      // Make a clone here in case the underlying byte[] is modified.
+      return sessionId.clone();
+    } catch (RuntimeException e) { // TODO: Drop this?
+      Log.e(TAG, "Cannot open a new session", e);
+      release();
+      return null;
+    } catch (NotProvisionedException e) {
+      // Throw NotProvisionedException so that we can attemptProvisioning().
+      throw e;
+    } catch (MediaDrmException e) {
+      // Other MediaDrmExceptions (e.g. ResourceBusyException) are not
+      // recoverable.
+      Log.e(TAG, "Cannot open a new session", e);
+      release();
+      return null;
+    }
+  }
+
+  /**
+   * Attempt to get the device that we are currently running on provisioned.
+   *
+   * @return whether provisioning was successful or not.
+   */
+  private boolean attemptProvisioning() {
+    Log.d(TAG, "attemptProvisioning()");
+    MediaDrm.ProvisionRequest request = mMediaDrm.getProvisionRequest();
+    String url = request.getDefaultUrl() + "&signedRequest=" + new String(request.getData());
+    byte[] response = new CobaltHttpHelper().performDrmHttpPost(url);
+    if (response == null) {
+      return false;
+    }
+    try {
+      mMediaDrm.provideProvisionResponse(response);
+      return true;
+    } catch (android.media.DeniedByServerException e) {
+      Log.e(TAG, "failed to provide provision response", e);
+    } catch (java.lang.IllegalStateException e) {
+      Log.e(TAG, "failed to provide provision response", e);
+    }
+    return false;
+  }
+
+  /**
+   * Check whether |sessionId| is an existing session ID, excluding the media crypto session.
+   *
+   * @param sessionId Crypto session Id.
+   * @return true if |sessionId| exists, false otherwise.
+   */
+  private boolean sessionExists(byte[] sessionId) {
+    if (mMediaCryptoSession == null) {
+      if (!mSessionIds.isEmpty()) {
+        throw new IllegalStateException(
+            "mSessionIds must be empty if crypto session does not exist.");
+      }
+      Log.e(TAG, "Session doesn't exist because media crypto session is not created.");
+      return false;
+    }
+    return !Arrays.equals(sessionId, mMediaCryptoSession)
+        && mSessionIds.containsKey(ByteBuffer.wrap(sessionId));
+  }
+
+  /** Release all allocated resources and finish all pending operations. */
+  private void release() {
+    // Note that mNativeMediaDrmBridge may have already been reset (see destroy()).
+    if (mMediaDrm == null) {
+      throw new IllegalStateException("Called release with null mMediaDrm.");
+    }
+
+    // Close all open sessions.
+    for (ByteBuffer sessionId : mSessionIds.keySet()) {
+      try {
+        // Some implementations don't have removeKeys.
+        // https://bugs.chromium.org/p/chromium/issues/detail?id=475632
+        mMediaDrm.removeKeys(sessionId.array());
+      } catch (Exception e) {
+        Log.e(TAG, "removeKeys failed: ", e);
+      }
+
+      try {
+        // Some implementations let this method throw exceptions.
+        mMediaDrm.closeSession(sessionId.array());
+      } catch (Exception e) {
+        Log.e(TAG, "closeSession failed: ", e);
+      }
+      Log.d(
+          TAG,
+          String.format("Successfully closed session (%s)", bytesToHexString(sessionId.array())));
+    }
+    mSessionIds.clear();
+    mSessionIds = null;
+
+    // Close mMediaCryptoSession if it's open.
+    if (mMediaCryptoSession != null) {
+      try {
+        // Some implementations let this method throw exceptions.
+        mMediaDrm.closeSession(mMediaCryptoSession);
+      } catch (Exception e) {
+        Log.e(TAG, "closeSession failed: ", e);
+      }
+      mMediaCryptoSession = null;
+    }
+
+    if (mMediaDrm != null) {
+      mMediaDrm.release();
+      mMediaDrm = null;
+    }
+  }
+
+  private boolean isNativeMediaDrmBridgeValid() {
+    return mNativeMediaDrmBridge != INVALID_NATIVE_MEDIA_DRM_BRIDGE;
+  }
+
+  private native void nativeOnSessionMessage(
+      long nativeMediaDrmBridge, int ticket, byte[] sessionId, int requestType, byte[] message);
+
+  private native void nativeOnKeyStatusChange(
+      long nativeMediaDrmBridge, byte[] sessionId, MediaDrm.KeyStatus[] keyInformation);
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/MediaImage.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/MediaImage.java
new file mode 100644
index 0000000..899dc22
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/MediaImage.java
@@ -0,0 +1,31 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.media;
+
+import dev.cobalt.util.UsedByNative;
+
+/** https://wicg.github.io/mediasession/#dictdef-mediaimage */
+public class MediaImage {
+  public final String src;
+  public final String sizes;
+  public final String type;
+
+  @UsedByNative
+  public MediaImage(String src, String sizes, String type) {
+    this.src = src;
+    this.sizes = sizes;
+    this.type = type;
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/VideoFrameReleaseTimeHelper.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/VideoFrameReleaseTimeHelper.java
new file mode 100644
index 0000000..4287c8c
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/VideoFrameReleaseTimeHelper.java
@@ -0,0 +1,327 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+// Modifications Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.media;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.view.Choreographer;
+import android.view.Choreographer.FrameCallback;
+import android.view.WindowManager;
+import dev.cobalt.util.UsedByNative;
+
+/** Makes a best effort to adjust frame release timestamps for a smoother visual result. */
+@TargetApi(16)
+@SuppressWarnings("unused")
+@UsedByNative
+public final class VideoFrameReleaseTimeHelper {
+
+  private static final double DISPLAY_REFRESH_RATE_UNKNOWN = -1;
+  private static final long CHOREOGRAPHER_SAMPLE_DELAY_MILLIS = 500;
+  private static final long MAX_ALLOWED_DRIFT_NS = 20000000;
+
+  private static final long VSYNC_OFFSET_PERCENTAGE = 80;
+  private static final int MIN_FRAMES_FOR_ADJUSTMENT = 6;
+  private static final long NANOS_PER_SECOND = 1000000000L;
+
+  private final VSyncSampler vsyncSampler;
+  private final boolean useDefaultDisplayVsync;
+  private final long vsyncDurationNs;
+  private final long vsyncOffsetNs;
+
+  private long lastFramePresentationTimeUs;
+  private long adjustedLastFrameTimeNs;
+  private long pendingAdjustedFrameTimeNs;
+
+  private boolean haveSync;
+  private long syncUnadjustedReleaseTimeNs;
+  private long syncFramePresentationTimeNs;
+  private long frameCount;
+
+  /**
+   * Constructs an instance that smooths frame release timestamps but does not align them with the
+   * default display's vsync signal.
+   */
+  @SuppressWarnings("unused")
+  public VideoFrameReleaseTimeHelper() {
+    this(DISPLAY_REFRESH_RATE_UNKNOWN);
+  }
+
+  /**
+   * Constructs an instance that smooths frame release timestamps and aligns them with the default
+   * display's vsync signal.
+   *
+   * @param context A context from which information about the default display can be retrieved.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public VideoFrameReleaseTimeHelper(Context context) {
+    this(getDefaultDisplayRefreshRate(context));
+  }
+
+  private VideoFrameReleaseTimeHelper(double defaultDisplayRefreshRate) {
+    useDefaultDisplayVsync = defaultDisplayRefreshRate != DISPLAY_REFRESH_RATE_UNKNOWN;
+    if (useDefaultDisplayVsync) {
+      vsyncSampler = VSyncSampler.getInstance();
+      vsyncDurationNs = (long) (NANOS_PER_SECOND / defaultDisplayRefreshRate);
+      vsyncOffsetNs = (vsyncDurationNs * VSYNC_OFFSET_PERCENTAGE) / 100;
+    } else {
+      vsyncSampler = null;
+      vsyncDurationNs = -1; // Value unused.
+      vsyncOffsetNs = -1; // Value unused.
+    }
+  }
+
+  /** Enables the helper. */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public void enable() {
+    haveSync = false;
+    if (useDefaultDisplayVsync) {
+      vsyncSampler.addObserver();
+    }
+  }
+
+  /** Disables the helper. */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public void disable() {
+    if (useDefaultDisplayVsync) {
+      vsyncSampler.removeObserver();
+    }
+  }
+
+  /**
+   * Adjusts a frame release timestamp.
+   *
+   * @param framePresentationTimeUs The frame's presentation time, in microseconds.
+   * @param unadjustedReleaseTimeNs The frame's unadjusted release time, in nanoseconds and in the
+   *     same time base as {@link System#nanoTime()}.
+   * @return The adjusted frame release timestamp, in nanoseconds and in the same time base as
+   *     {@link System#nanoTime()}.
+   */
+  @SuppressWarnings("unused")
+  @UsedByNative
+  public long adjustReleaseTime(long framePresentationTimeUs, long unadjustedReleaseTimeNs) {
+    long framePresentationTimeNs = framePresentationTimeUs * 1000;
+
+    // Until we know better, the adjustment will be a no-op.
+    long adjustedFrameTimeNs = framePresentationTimeNs;
+    long adjustedReleaseTimeNs = unadjustedReleaseTimeNs;
+
+    if (haveSync) {
+      // See if we've advanced to the next frame.
+      if (framePresentationTimeUs != lastFramePresentationTimeUs) {
+        frameCount++;
+        adjustedLastFrameTimeNs = pendingAdjustedFrameTimeNs;
+      }
+      if (frameCount >= MIN_FRAMES_FOR_ADJUSTMENT) {
+        // We're synced and have waited the required number of frames to apply an adjustment.
+        // Calculate the average frame time across all the frames we've seen since the last sync.
+        // This will typically give us a frame rate at a finer granularity than the frame times
+        // themselves (which often only have millisecond granularity).
+        long averageFrameDurationNs =
+            (framePresentationTimeNs - syncFramePresentationTimeNs) / frameCount;
+        // Project the adjusted frame time forward using the average.
+        long candidateAdjustedFrameTimeNs = adjustedLastFrameTimeNs + averageFrameDurationNs;
+
+        if (isDriftTooLarge(candidateAdjustedFrameTimeNs, unadjustedReleaseTimeNs)) {
+          haveSync = false;
+        } else {
+          adjustedFrameTimeNs = candidateAdjustedFrameTimeNs;
+          adjustedReleaseTimeNs =
+              syncUnadjustedReleaseTimeNs + adjustedFrameTimeNs - syncFramePresentationTimeNs;
+        }
+      } else {
+        // We're synced but haven't waited the required number of frames to apply an adjustment.
+        // Check drift anyway.
+        if (isDriftTooLarge(framePresentationTimeNs, unadjustedReleaseTimeNs)) {
+          haveSync = false;
+        }
+      }
+    }
+
+    // If we need to sync, do so now.
+    if (!haveSync) {
+      syncFramePresentationTimeNs = framePresentationTimeNs;
+      syncUnadjustedReleaseTimeNs = unadjustedReleaseTimeNs;
+      frameCount = 0;
+      haveSync = true;
+      onSynced();
+    }
+
+    lastFramePresentationTimeUs = framePresentationTimeUs;
+    pendingAdjustedFrameTimeNs = adjustedFrameTimeNs;
+
+    if (vsyncSampler == null || vsyncSampler.sampledVsyncTimeNs == 0) {
+      return adjustedReleaseTimeNs;
+    }
+
+    // Find the timestamp of the closest vsync. This is the vsync that we're targeting.
+    long snappedTimeNs =
+        closestVsync(adjustedReleaseTimeNs, vsyncSampler.sampledVsyncTimeNs, vsyncDurationNs);
+    // Apply an offset so that we release before the target vsync, but after the previous one.
+    return snappedTimeNs - vsyncOffsetNs;
+  }
+
+  protected void onSynced() {
+    // Do nothing.
+  }
+
+  private boolean isDriftTooLarge(long frameTimeNs, long releaseTimeNs) {
+    long elapsedFrameTimeNs = frameTimeNs - syncFramePresentationTimeNs;
+    long elapsedReleaseTimeNs = releaseTimeNs - syncUnadjustedReleaseTimeNs;
+    return Math.abs(elapsedReleaseTimeNs - elapsedFrameTimeNs) > MAX_ALLOWED_DRIFT_NS;
+  }
+
+  private static long closestVsync(long releaseTime, long sampledVsyncTime, long vsyncDuration) {
+    long vsyncCount = (releaseTime - sampledVsyncTime) / vsyncDuration;
+    long snappedTimeNs = sampledVsyncTime + (vsyncDuration * vsyncCount);
+    long snappedBeforeNs;
+    long snappedAfterNs;
+    if (releaseTime <= snappedTimeNs) {
+      snappedBeforeNs = snappedTimeNs - vsyncDuration;
+      snappedAfterNs = snappedTimeNs;
+    } else {
+      snappedBeforeNs = snappedTimeNs;
+      snappedAfterNs = snappedTimeNs + vsyncDuration;
+    }
+    long snappedAfterDiff = snappedAfterNs - releaseTime;
+    long snappedBeforeDiff = releaseTime - snappedBeforeNs;
+    return snappedAfterDiff < snappedBeforeDiff ? snappedAfterNs : snappedBeforeNs;
+  }
+
+  private static double getDefaultDisplayRefreshRate(Context context) {
+    WindowManager manager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
+    return manager.getDefaultDisplay() != null
+        ? manager.getDefaultDisplay().getRefreshRate()
+        : DISPLAY_REFRESH_RATE_UNKNOWN;
+  }
+
+  /**
+   * Samples display vsync timestamps. A single instance using a single {@link Choreographer} is
+   * shared by all {@link VideoFrameReleaseTimeHelper} instances. This is done to avoid a resource
+   * leak in the platform on API levels prior to 23.
+   */
+  private static final class VSyncSampler implements FrameCallback, Handler.Callback {
+
+    public volatile long sampledVsyncTimeNs;
+
+    private static final int CREATE_CHOREOGRAPHER = 0;
+    private static final int MSG_ADD_OBSERVER = 1;
+    private static final int MSG_REMOVE_OBSERVER = 2;
+
+    private static final VSyncSampler INSTANCE = new VSyncSampler();
+
+    private final Handler handler;
+    private final HandlerThread choreographerOwnerThread;
+    private Choreographer choreographer;
+    private int observerCount;
+
+    public static VSyncSampler getInstance() {
+      return INSTANCE;
+    }
+
+    private VSyncSampler() {
+      choreographerOwnerThread = new HandlerThread("ChoreographerOwner:Handler");
+      choreographerOwnerThread.start();
+      handler = new Handler(choreographerOwnerThread.getLooper(), this);
+      handler.sendEmptyMessage(CREATE_CHOREOGRAPHER);
+    }
+
+    /**
+     * Notifies the sampler that a {@link VideoFrameReleaseTimeHelper} is observing {@link
+     * #sampledVsyncTimeNs}, and hence that the value should be periodically updated.
+     */
+    public void addObserver() {
+      handler.sendEmptyMessage(MSG_ADD_OBSERVER);
+    }
+
+    /**
+     * Notifies the sampler that a {@link VideoFrameReleaseTimeHelper} is no longer observing {@link
+     * #sampledVsyncTimeNs}.
+     */
+    public void removeObserver() {
+      handler.sendEmptyMessage(MSG_REMOVE_OBSERVER);
+    }
+
+    @Override
+    public void doFrame(long vsyncTimeNs) {
+      sampledVsyncTimeNs = vsyncTimeNs;
+      choreographer.postFrameCallbackDelayed(this, CHOREOGRAPHER_SAMPLE_DELAY_MILLIS);
+    }
+
+    @Override
+    public boolean handleMessage(Message message) {
+      switch (message.what) {
+        case CREATE_CHOREOGRAPHER:
+          {
+            createChoreographerInstanceInternal();
+            return true;
+          }
+        case MSG_ADD_OBSERVER:
+          {
+            addObserverInternal();
+            return true;
+          }
+        case MSG_REMOVE_OBSERVER:
+          {
+            removeObserverInternal();
+            return true;
+          }
+        default:
+          {
+            return false;
+          }
+      }
+    }
+
+    private void createChoreographerInstanceInternal() {
+      choreographer = Choreographer.getInstance();
+    }
+
+    private void addObserverInternal() {
+      observerCount++;
+      if (observerCount == 1) {
+        choreographer.postFrameCallback(this);
+      }
+    }
+
+    private void removeObserverInternal() {
+      observerCount--;
+      if (observerCount == 0) {
+        choreographer.removeFrameCallback(this);
+        sampledVsyncTimeNs = 0;
+      }
+    }
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/VideoSurfaceView.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/VideoSurfaceView.java
new file mode 100644
index 0000000..2478b97
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/media/VideoSurfaceView.java
@@ -0,0 +1,108 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.media;
+
+import static dev.cobalt.media.Log.TAG;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import dev.cobalt.util.Log;
+
+/**
+ * A Surface view to be used by the video decoder. It informs the Starboard application when the
+ * surface is available so that the decoder can get a reference to it.
+ */
+public class VideoSurfaceView extends SurfaceView {
+
+  public static native void nativeOnLayoutNeeded();
+
+  public static native void nativeOnLayoutScheduled();
+
+  public static native void nativeOnGlobalLayout();
+
+  private Rect videoBounds;
+
+  public VideoSurfaceView(Context context) {
+    super(context);
+    initialize(context);
+  }
+
+  public VideoSurfaceView(Context context, AttributeSet attrs) {
+    super(context, attrs);
+    initialize(context);
+  }
+
+  public VideoSurfaceView(Context context, AttributeSet attrs, int defStyleAttr) {
+    super(context, attrs, defStyleAttr);
+    initialize(context);
+  }
+
+  public VideoSurfaceView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+    super(context, attrs, defStyleAttr, defStyleRes);
+    initialize(context);
+  }
+
+  private void initialize(Context context) {
+    videoBounds = new Rect();
+    setBackgroundColor(Color.TRANSPARENT);
+    getHolder().addCallback(new SurfaceHolderCallback());
+
+    // TODO: Avoid recreating the surface when the player bounds change.
+    // Recreating the surface is time-consuming and complicates synchronizing
+    // punch-out video when the position / size is animated.
+  }
+
+  public boolean updateVideoBounds(final int x, final int y, final int width, final int height) {
+    if (videoBounds.left != x
+        || videoBounds.top != y
+        || videoBounds.right != x + width
+        || videoBounds.bottom != y + height) {
+      videoBounds.set(x, y, x + width, y + height);
+      return true;
+    }
+    return false;
+  }
+
+  private native void nativeOnVideoSurfaceChanged(Surface surface);
+
+  private class SurfaceHolderCallback implements SurfaceHolder.Callback {
+
+    boolean sawInitialChange = false;
+
+    @Override
+    public void surfaceCreated(SurfaceHolder holder) {
+      nativeOnVideoSurfaceChanged(holder.getSurface());
+    }
+
+    @Override
+    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
+      // We should only ever see the initial change after creation.
+      if (sawInitialChange) {
+        Log.e(TAG, "Video surface changed; decoding may break");
+      }
+      sawInitialChange = true;
+    }
+
+    @Override
+    public void surfaceDestroyed(SurfaceHolder holder) {
+      nativeOnVideoSurfaceChanged(null);
+    }
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/storage/CobaltStorageLoader.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/storage/CobaltStorageLoader.java
new file mode 100644
index 0000000..315e679
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/storage/CobaltStorageLoader.java
@@ -0,0 +1,124 @@
+// Copyright 2018 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.storage;
+
+import static dev.cobalt.util.Log.TAG;
+
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import com.google.protobuf.InvalidProtocolBufferException;
+import dev.cobalt.storage.StorageProto.Storage;
+import dev.cobalt.util.Log;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.Arrays;
+
+/** A class to load Cobalt storage from the file system. */
+public class CobaltStorageLoader {
+  private final File filesDir;
+
+  private static final String STORAGE_NAME_PREFIX = ".starboard";
+  private static final String STORAGE_NAME_SUFFIX = ".storage";
+  private static final String STORAGE_HEADER = "SAV1";
+
+  /**
+   * Initializes the loader with the files directory root.
+   *
+   * @param filesDir a File object representing the files directory.
+   */
+  public CobaltStorageLoader(File filesDir) {
+    if (filesDir == null) {
+      throw new IllegalArgumentException("A valid filesDir object is required");
+    }
+    this.filesDir = filesDir;
+  }
+
+  /**
+   * Reads synchronously the Cobalt storage from the file system.
+   *
+   * @return a snapshot of the Cobalt storage as a proto message.
+   */
+  public Storage loadStorageSnapshot() {
+    String fileName = getStorageFileName();
+    if (fileName == null) {
+      return Storage.getDefaultInstance();
+    }
+    byte[] storageBlob = getStorageBlob(fileName);
+    if (storageBlob == null) {
+      Log.e(TAG, "Failed to get storage blob");
+      return Storage.getDefaultInstance();
+    }
+    if (!validateStorageBlob(storageBlob)) {
+      Log.e(TAG, "Invalid storage blob");
+      return Storage.getDefaultInstance();
+    }
+    storageBlob = Arrays.copyOfRange(storageBlob, STORAGE_HEADER.length(), storageBlob.length);
+    try {
+      return Storage.parseFrom(storageBlob);
+    } catch (InvalidProtocolBufferException e) {
+      Log.e(TAG, "Failed parsing the blob", e);
+    }
+    return Storage.getDefaultInstance();
+  }
+
+  private boolean validateStorageBlob(byte[] storageBlob) {
+    if (storageBlob == null || storageBlob.length < STORAGE_HEADER.length()) {
+      return false;
+    }
+    String header = new String(storageBlob, 0, STORAGE_HEADER.length());
+    return header.equals(STORAGE_HEADER);
+  }
+
+  @Nullable
+  private byte[] getStorageBlob(String fileName) {
+    if (TextUtils.isEmpty(fileName)) {
+      Log.e(TAG, "Invalid empty file name");
+      return null;
+    }
+    try (FileInputStream in = new FileInputStream(new File(filesDir, fileName));
+        ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+      byte[] buffer = new byte[8192];
+      int len;
+      while ((len = in.read(buffer)) != -1) {
+        out.write(buffer, 0, len);
+      }
+      return out.toByteArray();
+    } catch (IOException e) {
+      Log.e(TAG, "Failed to read storage blob", e);
+    }
+    return null;
+  }
+
+  @Nullable
+  private String getStorageFileName() {
+    String[] fileNames = filesDir.list();
+    if (fileNames == null) {
+      Log.w(TAG, "Empty file list");
+      return null;
+    }
+    for (String fileName : fileNames) {
+      if (fileName.startsWith(STORAGE_NAME_PREFIX) && fileName.endsWith(STORAGE_NAME_SUFFIX)) {
+        File storageFile = new File(filesDir, fileName);
+        if (storageFile.length() > 0) {
+          return fileName;
+        }
+      }
+    }
+    Log.w(TAG, "Failed to find storage file name");
+    return null;
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/storage/README.md b/src/starboard/android/apk/app/src/main/java/dev/cobalt/storage/README.md
new file mode 100644
index 0000000..7938fc5
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/storage/README.md
@@ -0,0 +1,15 @@
+The StorageProto java class was generated using the protoc
+compiler from the third_party/protobuf package.
+
+Example:
+ out/linux-x64x11_debug/protoc cobalt/storage/store/storage.proto \
+     --java_out=starboard/android/apk/app/src/main/java
+
+The code doesn't compile cleanly and produces warning:
+ warning: [unchecked] unchecked conversion
+
+To suppress the warning the following annotation should be added
+to the StorageProto class:
+
+@SuppressWarnings("unchecked")
+
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/storage/StorageProto.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/storage/StorageProto.java
new file mode 100644
index 0000000..a07b35d
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/storage/StorageProto.java
@@ -0,0 +1,3591 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// source: cobalt/storage/store/storage.proto
+
+package dev.cobalt.storage;
+
+@SuppressWarnings("unchecked")
+public final class StorageProto {
+  private StorageProto() {}
+  public static void registerAllExtensions(
+      com.google.protobuf.ExtensionRegistryLite registry) {
+  }
+  public interface CookieOrBuilder extends
+      // @@protoc_insertion_point(interface_extends:cobalt.storage.Cookie)
+      com.google.protobuf.MessageLiteOrBuilder {
+
+    /**
+     * <pre>
+     * The name of the cookie.
+     * </pre>
+     *
+     * <code>optional string name = 1;</code>
+     */
+    java.lang.String getName();
+    /**
+     * <pre>
+     * The name of the cookie.
+     * </pre>
+     *
+     * <code>optional string name = 1;</code>
+     */
+    com.google.protobuf.ByteString
+        getNameBytes();
+
+    /**
+     * <pre>
+     * The value of the cookie.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    java.lang.String getValue();
+    /**
+     * <pre>
+     * The value of the cookie.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    com.google.protobuf.ByteString
+        getValueBytes();
+
+    /**
+     * <pre>
+     * The domain of the url for which the cookie is store.
+     * </pre>
+     *
+     * <code>optional string domain = 3;</code>
+     */
+    java.lang.String getDomain();
+    /**
+     * <pre>
+     * The domain of the url for which the cookie is store.
+     * </pre>
+     *
+     * <code>optional string domain = 3;</code>
+     */
+    com.google.protobuf.ByteString
+        getDomainBytes();
+
+    /**
+     * <pre>
+     * The path of the url for which the cookie is stored.
+     * </pre>
+     *
+     * <code>optional string path = 4;</code>
+     */
+    java.lang.String getPath();
+    /**
+     * <pre>
+     * The path of the url for which the cookie is stored.
+     * </pre>
+     *
+     * <code>optional string path = 4;</code>
+     */
+    com.google.protobuf.ByteString
+        getPathBytes();
+
+    /**
+     * <pre>
+     * The creation time for the cookie in microseconds since
+     * Windows epoch - 1/1/1601 UTC.
+     * </pre>
+     *
+     * <code>optional int64 creation_time_us = 5;</code>
+     */
+    long getCreationTimeUs();
+
+    /**
+     * <pre>
+     * The expiration time for the cookie in microseconds since
+     * Windows epoch - 1/1/1601 UTC.
+     * </pre>
+     *
+     * <code>optional int64 expiration_time_us = 6;</code>
+     */
+    long getExpirationTimeUs();
+
+    /**
+     * <pre>
+     * The last access time in microseconds since
+     * Windows epoch - 1/1/1601 UTC.
+     * </pre>
+     *
+     * <code>optional int64 last_access_time_us = 7;</code>
+     */
+    long getLastAccessTimeUs();
+
+    /**
+     * <pre>
+     * Whether the cookie should be transmitted only over secure connection.
+     * Defaults to false.
+     * </pre>
+     *
+     * <code>optional bool secure = 8;</code>
+     */
+    boolean getSecure();
+
+    /**
+     * <pre>
+     * Whether this is an HTTP-only cookie. Defaults to false.
+     * </pre>
+     *
+     * <code>optional bool http_only = 9;</code>
+     */
+    boolean getHttpOnly();
+  }
+  /**
+   * <pre>
+   * A single cookie representation.
+   * </pre>
+   *
+   * Protobuf type {@code cobalt.storage.Cookie}
+   */
+  public  static final class Cookie extends
+      com.google.protobuf.GeneratedMessageLite<
+          Cookie, Cookie.Builder> implements
+      // @@protoc_insertion_point(message_implements:cobalt.storage.Cookie)
+      CookieOrBuilder {
+    private Cookie() {
+      name_ = "";
+      value_ = "";
+      domain_ = "";
+      path_ = "";
+      creationTimeUs_ = 0L;
+      expirationTimeUs_ = 0L;
+      lastAccessTimeUs_ = 0L;
+      secure_ = false;
+      httpOnly_ = false;
+    }
+    public static final int NAME_FIELD_NUMBER = 1;
+    private java.lang.String name_;
+    /**
+     * <pre>
+     * The name of the cookie.
+     * </pre>
+     *
+     * <code>optional string name = 1;</code>
+     */
+    public java.lang.String getName() {
+      return name_;
+    }
+    /**
+     * <pre>
+     * The name of the cookie.
+     * </pre>
+     *
+     * <code>optional string name = 1;</code>
+     */
+    public com.google.protobuf.ByteString
+        getNameBytes() {
+      return com.google.protobuf.ByteString.copyFromUtf8(name_);
+    }
+    /**
+     * <pre>
+     * The name of the cookie.
+     * </pre>
+     *
+     * <code>optional string name = 1;</code>
+     */
+    private void setName(
+        java.lang.String value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  
+      name_ = value;
+    }
+    /**
+     * <pre>
+     * The name of the cookie.
+     * </pre>
+     *
+     * <code>optional string name = 1;</code>
+     */
+    private void clearName() {
+      
+      name_ = getDefaultInstance().getName();
+    }
+    /**
+     * <pre>
+     * The name of the cookie.
+     * </pre>
+     *
+     * <code>optional string name = 1;</code>
+     */
+    private void setNameBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  checkByteStringIsUtf8(value);
+      
+      name_ = value.toStringUtf8();
+    }
+
+    public static final int VALUE_FIELD_NUMBER = 2;
+    private java.lang.String value_;
+    /**
+     * <pre>
+     * The value of the cookie.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    public java.lang.String getValue() {
+      return value_;
+    }
+    /**
+     * <pre>
+     * The value of the cookie.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    public com.google.protobuf.ByteString
+        getValueBytes() {
+      return com.google.protobuf.ByteString.copyFromUtf8(value_);
+    }
+    /**
+     * <pre>
+     * The value of the cookie.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    private void setValue(
+        java.lang.String value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  
+      value_ = value;
+    }
+    /**
+     * <pre>
+     * The value of the cookie.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    private void clearValue() {
+      
+      value_ = getDefaultInstance().getValue();
+    }
+    /**
+     * <pre>
+     * The value of the cookie.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    private void setValueBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  checkByteStringIsUtf8(value);
+      
+      value_ = value.toStringUtf8();
+    }
+
+    public static final int DOMAIN_FIELD_NUMBER = 3;
+    private java.lang.String domain_;
+    /**
+     * <pre>
+     * The domain of the url for which the cookie is store.
+     * </pre>
+     *
+     * <code>optional string domain = 3;</code>
+     */
+    public java.lang.String getDomain() {
+      return domain_;
+    }
+    /**
+     * <pre>
+     * The domain of the url for which the cookie is store.
+     * </pre>
+     *
+     * <code>optional string domain = 3;</code>
+     */
+    public com.google.protobuf.ByteString
+        getDomainBytes() {
+      return com.google.protobuf.ByteString.copyFromUtf8(domain_);
+    }
+    /**
+     * <pre>
+     * The domain of the url for which the cookie is store.
+     * </pre>
+     *
+     * <code>optional string domain = 3;</code>
+     */
+    private void setDomain(
+        java.lang.String value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  
+      domain_ = value;
+    }
+    /**
+     * <pre>
+     * The domain of the url for which the cookie is store.
+     * </pre>
+     *
+     * <code>optional string domain = 3;</code>
+     */
+    private void clearDomain() {
+      
+      domain_ = getDefaultInstance().getDomain();
+    }
+    /**
+     * <pre>
+     * The domain of the url for which the cookie is store.
+     * </pre>
+     *
+     * <code>optional string domain = 3;</code>
+     */
+    private void setDomainBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  checkByteStringIsUtf8(value);
+      
+      domain_ = value.toStringUtf8();
+    }
+
+    public static final int PATH_FIELD_NUMBER = 4;
+    private java.lang.String path_;
+    /**
+     * <pre>
+     * The path of the url for which the cookie is stored.
+     * </pre>
+     *
+     * <code>optional string path = 4;</code>
+     */
+    public java.lang.String getPath() {
+      return path_;
+    }
+    /**
+     * <pre>
+     * The path of the url for which the cookie is stored.
+     * </pre>
+     *
+     * <code>optional string path = 4;</code>
+     */
+    public com.google.protobuf.ByteString
+        getPathBytes() {
+      return com.google.protobuf.ByteString.copyFromUtf8(path_);
+    }
+    /**
+     * <pre>
+     * The path of the url for which the cookie is stored.
+     * </pre>
+     *
+     * <code>optional string path = 4;</code>
+     */
+    private void setPath(
+        java.lang.String value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  
+      path_ = value;
+    }
+    /**
+     * <pre>
+     * The path of the url for which the cookie is stored.
+     * </pre>
+     *
+     * <code>optional string path = 4;</code>
+     */
+    private void clearPath() {
+      
+      path_ = getDefaultInstance().getPath();
+    }
+    /**
+     * <pre>
+     * The path of the url for which the cookie is stored.
+     * </pre>
+     *
+     * <code>optional string path = 4;</code>
+     */
+    private void setPathBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  checkByteStringIsUtf8(value);
+      
+      path_ = value.toStringUtf8();
+    }
+
+    public static final int CREATION_TIME_US_FIELD_NUMBER = 5;
+    private long creationTimeUs_;
+    /**
+     * <pre>
+     * The creation time for the cookie in microseconds since
+     * Windows epoch - 1/1/1601 UTC.
+     * </pre>
+     *
+     * <code>optional int64 creation_time_us = 5;</code>
+     */
+    public long getCreationTimeUs() {
+      return creationTimeUs_;
+    }
+    /**
+     * <pre>
+     * The creation time for the cookie in microseconds since
+     * Windows epoch - 1/1/1601 UTC.
+     * </pre>
+     *
+     * <code>optional int64 creation_time_us = 5;</code>
+     */
+    private void setCreationTimeUs(long value) {
+      
+      creationTimeUs_ = value;
+    }
+    /**
+     * <pre>
+     * The creation time for the cookie in microseconds since
+     * Windows epoch - 1/1/1601 UTC.
+     * </pre>
+     *
+     * <code>optional int64 creation_time_us = 5;</code>
+     */
+    private void clearCreationTimeUs() {
+      
+      creationTimeUs_ = 0L;
+    }
+
+    public static final int EXPIRATION_TIME_US_FIELD_NUMBER = 6;
+    private long expirationTimeUs_;
+    /**
+     * <pre>
+     * The expiration time for the cookie in microseconds since
+     * Windows epoch - 1/1/1601 UTC.
+     * </pre>
+     *
+     * <code>optional int64 expiration_time_us = 6;</code>
+     */
+    public long getExpirationTimeUs() {
+      return expirationTimeUs_;
+    }
+    /**
+     * <pre>
+     * The expiration time for the cookie in microseconds since
+     * Windows epoch - 1/1/1601 UTC.
+     * </pre>
+     *
+     * <code>optional int64 expiration_time_us = 6;</code>
+     */
+    private void setExpirationTimeUs(long value) {
+      
+      expirationTimeUs_ = value;
+    }
+    /**
+     * <pre>
+     * The expiration time for the cookie in microseconds since
+     * Windows epoch - 1/1/1601 UTC.
+     * </pre>
+     *
+     * <code>optional int64 expiration_time_us = 6;</code>
+     */
+    private void clearExpirationTimeUs() {
+      
+      expirationTimeUs_ = 0L;
+    }
+
+    public static final int LAST_ACCESS_TIME_US_FIELD_NUMBER = 7;
+    private long lastAccessTimeUs_;
+    /**
+     * <pre>
+     * The last access time in microseconds since
+     * Windows epoch - 1/1/1601 UTC.
+     * </pre>
+     *
+     * <code>optional int64 last_access_time_us = 7;</code>
+     */
+    public long getLastAccessTimeUs() {
+      return lastAccessTimeUs_;
+    }
+    /**
+     * <pre>
+     * The last access time in microseconds since
+     * Windows epoch - 1/1/1601 UTC.
+     * </pre>
+     *
+     * <code>optional int64 last_access_time_us = 7;</code>
+     */
+    private void setLastAccessTimeUs(long value) {
+      
+      lastAccessTimeUs_ = value;
+    }
+    /**
+     * <pre>
+     * The last access time in microseconds since
+     * Windows epoch - 1/1/1601 UTC.
+     * </pre>
+     *
+     * <code>optional int64 last_access_time_us = 7;</code>
+     */
+    private void clearLastAccessTimeUs() {
+      
+      lastAccessTimeUs_ = 0L;
+    }
+
+    public static final int SECURE_FIELD_NUMBER = 8;
+    private boolean secure_;
+    /**
+     * <pre>
+     * Whether the cookie should be transmitted only over secure connection.
+     * Defaults to false.
+     * </pre>
+     *
+     * <code>optional bool secure = 8;</code>
+     */
+    public boolean getSecure() {
+      return secure_;
+    }
+    /**
+     * <pre>
+     * Whether the cookie should be transmitted only over secure connection.
+     * Defaults to false.
+     * </pre>
+     *
+     * <code>optional bool secure = 8;</code>
+     */
+    private void setSecure(boolean value) {
+      
+      secure_ = value;
+    }
+    /**
+     * <pre>
+     * Whether the cookie should be transmitted only over secure connection.
+     * Defaults to false.
+     * </pre>
+     *
+     * <code>optional bool secure = 8;</code>
+     */
+    private void clearSecure() {
+      
+      secure_ = false;
+    }
+
+    public static final int HTTP_ONLY_FIELD_NUMBER = 9;
+    private boolean httpOnly_;
+    /**
+     * <pre>
+     * Whether this is an HTTP-only cookie. Defaults to false.
+     * </pre>
+     *
+     * <code>optional bool http_only = 9;</code>
+     */
+    public boolean getHttpOnly() {
+      return httpOnly_;
+    }
+    /**
+     * <pre>
+     * Whether this is an HTTP-only cookie. Defaults to false.
+     * </pre>
+     *
+     * <code>optional bool http_only = 9;</code>
+     */
+    private void setHttpOnly(boolean value) {
+      
+      httpOnly_ = value;
+    }
+    /**
+     * <pre>
+     * Whether this is an HTTP-only cookie. Defaults to false.
+     * </pre>
+     *
+     * <code>optional bool http_only = 9;</code>
+     */
+    private void clearHttpOnly() {
+      
+      httpOnly_ = false;
+    }
+
+    public void writeTo(com.google.protobuf.CodedOutputStream output)
+                        throws java.io.IOException {
+      if (!name_.isEmpty()) {
+        output.writeString(1, getName());
+      }
+      if (!value_.isEmpty()) {
+        output.writeString(2, getValue());
+      }
+      if (!domain_.isEmpty()) {
+        output.writeString(3, getDomain());
+      }
+      if (!path_.isEmpty()) {
+        output.writeString(4, getPath());
+      }
+      if (creationTimeUs_ != 0L) {
+        output.writeInt64(5, creationTimeUs_);
+      }
+      if (expirationTimeUs_ != 0L) {
+        output.writeInt64(6, expirationTimeUs_);
+      }
+      if (lastAccessTimeUs_ != 0L) {
+        output.writeInt64(7, lastAccessTimeUs_);
+      }
+      if (secure_ != false) {
+        output.writeBool(8, secure_);
+      }
+      if (httpOnly_ != false) {
+        output.writeBool(9, httpOnly_);
+      }
+    }
+
+    public int getSerializedSize() {
+      int size = memoizedSerializedSize;
+      if (size != -1) return size;
+
+      size = 0;
+      if (!name_.isEmpty()) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeStringSize(1, getName());
+      }
+      if (!value_.isEmpty()) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeStringSize(2, getValue());
+      }
+      if (!domain_.isEmpty()) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeStringSize(3, getDomain());
+      }
+      if (!path_.isEmpty()) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeStringSize(4, getPath());
+      }
+      if (creationTimeUs_ != 0L) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeInt64Size(5, creationTimeUs_);
+      }
+      if (expirationTimeUs_ != 0L) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeInt64Size(6, expirationTimeUs_);
+      }
+      if (lastAccessTimeUs_ != 0L) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeInt64Size(7, lastAccessTimeUs_);
+      }
+      if (secure_ != false) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeBoolSize(8, secure_);
+      }
+      if (httpOnly_ != false) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeBoolSize(9, httpOnly_);
+      }
+      memoizedSerializedSize = size;
+      return size;
+    }
+
+    public static dev.cobalt.storage.StorageProto.Cookie parseFrom(
+        com.google.protobuf.ByteString data)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data);
+    }
+    public static dev.cobalt.storage.StorageProto.Cookie parseFrom(
+        com.google.protobuf.ByteString data,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.Cookie parseFrom(byte[] data)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data);
+    }
+    public static dev.cobalt.storage.StorageProto.Cookie parseFrom(
+        byte[] data,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.Cookie parseFrom(java.io.InputStream input)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input);
+    }
+    public static dev.cobalt.storage.StorageProto.Cookie parseFrom(
+        java.io.InputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.Cookie parseDelimitedFrom(java.io.InputStream input)
+        throws java.io.IOException {
+      return parseDelimitedFrom(DEFAULT_INSTANCE, input);
+    }
+    public static dev.cobalt.storage.StorageProto.Cookie parseDelimitedFrom(
+        java.io.InputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      return parseDelimitedFrom(DEFAULT_INSTANCE, input, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.Cookie parseFrom(
+        com.google.protobuf.CodedInputStream input)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input);
+    }
+    public static dev.cobalt.storage.StorageProto.Cookie parseFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input, extensionRegistry);
+    }
+
+    public static Builder newBuilder() {
+      return DEFAULT_INSTANCE.toBuilder();
+    }
+    public static Builder newBuilder(dev.cobalt.storage.StorageProto.Cookie prototype) {
+      return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+    }
+
+    /**
+     * <pre>
+     * A single cookie representation.
+     * </pre>
+     *
+     * Protobuf type {@code cobalt.storage.Cookie}
+     */
+    public static final class Builder extends
+        com.google.protobuf.GeneratedMessageLite.Builder<
+          dev.cobalt.storage.StorageProto.Cookie, Builder> implements
+        // @@protoc_insertion_point(builder_implements:cobalt.storage.Cookie)
+        dev.cobalt.storage.StorageProto.CookieOrBuilder {
+      // Construct using dev.cobalt.storage.StorageProto.Cookie.newBuilder()
+      private Builder() {
+        super(DEFAULT_INSTANCE);
+      }
+
+
+      /**
+       * <pre>
+       * The name of the cookie.
+       * </pre>
+       *
+       * <code>optional string name = 1;</code>
+       */
+      public java.lang.String getName() {
+        return instance.getName();
+      }
+      /**
+       * <pre>
+       * The name of the cookie.
+       * </pre>
+       *
+       * <code>optional string name = 1;</code>
+       */
+      public com.google.protobuf.ByteString
+          getNameBytes() {
+        return instance.getNameBytes();
+      }
+      /**
+       * <pre>
+       * The name of the cookie.
+       * </pre>
+       *
+       * <code>optional string name = 1;</code>
+       */
+      public Builder setName(
+          java.lang.String value) {
+        copyOnWrite();
+        instance.setName(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * The name of the cookie.
+       * </pre>
+       *
+       * <code>optional string name = 1;</code>
+       */
+      public Builder clearName() {
+        copyOnWrite();
+        instance.clearName();
+        return this;
+      }
+      /**
+       * <pre>
+       * The name of the cookie.
+       * </pre>
+       *
+       * <code>optional string name = 1;</code>
+       */
+      public Builder setNameBytes(
+          com.google.protobuf.ByteString value) {
+        copyOnWrite();
+        instance.setNameBytes(value);
+        return this;
+      }
+
+      /**
+       * <pre>
+       * The value of the cookie.
+       * </pre>
+       *
+       * <code>optional string value = 2;</code>
+       */
+      public java.lang.String getValue() {
+        return instance.getValue();
+      }
+      /**
+       * <pre>
+       * The value of the cookie.
+       * </pre>
+       *
+       * <code>optional string value = 2;</code>
+       */
+      public com.google.protobuf.ByteString
+          getValueBytes() {
+        return instance.getValueBytes();
+      }
+      /**
+       * <pre>
+       * The value of the cookie.
+       * </pre>
+       *
+       * <code>optional string value = 2;</code>
+       */
+      public Builder setValue(
+          java.lang.String value) {
+        copyOnWrite();
+        instance.setValue(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * The value of the cookie.
+       * </pre>
+       *
+       * <code>optional string value = 2;</code>
+       */
+      public Builder clearValue() {
+        copyOnWrite();
+        instance.clearValue();
+        return this;
+      }
+      /**
+       * <pre>
+       * The value of the cookie.
+       * </pre>
+       *
+       * <code>optional string value = 2;</code>
+       */
+      public Builder setValueBytes(
+          com.google.protobuf.ByteString value) {
+        copyOnWrite();
+        instance.setValueBytes(value);
+        return this;
+      }
+
+      /**
+       * <pre>
+       * The domain of the url for which the cookie is store.
+       * </pre>
+       *
+       * <code>optional string domain = 3;</code>
+       */
+      public java.lang.String getDomain() {
+        return instance.getDomain();
+      }
+      /**
+       * <pre>
+       * The domain of the url for which the cookie is store.
+       * </pre>
+       *
+       * <code>optional string domain = 3;</code>
+       */
+      public com.google.protobuf.ByteString
+          getDomainBytes() {
+        return instance.getDomainBytes();
+      }
+      /**
+       * <pre>
+       * The domain of the url for which the cookie is store.
+       * </pre>
+       *
+       * <code>optional string domain = 3;</code>
+       */
+      public Builder setDomain(
+          java.lang.String value) {
+        copyOnWrite();
+        instance.setDomain(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * The domain of the url for which the cookie is store.
+       * </pre>
+       *
+       * <code>optional string domain = 3;</code>
+       */
+      public Builder clearDomain() {
+        copyOnWrite();
+        instance.clearDomain();
+        return this;
+      }
+      /**
+       * <pre>
+       * The domain of the url for which the cookie is store.
+       * </pre>
+       *
+       * <code>optional string domain = 3;</code>
+       */
+      public Builder setDomainBytes(
+          com.google.protobuf.ByteString value) {
+        copyOnWrite();
+        instance.setDomainBytes(value);
+        return this;
+      }
+
+      /**
+       * <pre>
+       * The path of the url for which the cookie is stored.
+       * </pre>
+       *
+       * <code>optional string path = 4;</code>
+       */
+      public java.lang.String getPath() {
+        return instance.getPath();
+      }
+      /**
+       * <pre>
+       * The path of the url for which the cookie is stored.
+       * </pre>
+       *
+       * <code>optional string path = 4;</code>
+       */
+      public com.google.protobuf.ByteString
+          getPathBytes() {
+        return instance.getPathBytes();
+      }
+      /**
+       * <pre>
+       * The path of the url for which the cookie is stored.
+       * </pre>
+       *
+       * <code>optional string path = 4;</code>
+       */
+      public Builder setPath(
+          java.lang.String value) {
+        copyOnWrite();
+        instance.setPath(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * The path of the url for which the cookie is stored.
+       * </pre>
+       *
+       * <code>optional string path = 4;</code>
+       */
+      public Builder clearPath() {
+        copyOnWrite();
+        instance.clearPath();
+        return this;
+      }
+      /**
+       * <pre>
+       * The path of the url for which the cookie is stored.
+       * </pre>
+       *
+       * <code>optional string path = 4;</code>
+       */
+      public Builder setPathBytes(
+          com.google.protobuf.ByteString value) {
+        copyOnWrite();
+        instance.setPathBytes(value);
+        return this;
+      }
+
+      /**
+       * <pre>
+       * The creation time for the cookie in microseconds since
+       * Windows epoch - 1/1/1601 UTC.
+       * </pre>
+       *
+       * <code>optional int64 creation_time_us = 5;</code>
+       */
+      public long getCreationTimeUs() {
+        return instance.getCreationTimeUs();
+      }
+      /**
+       * <pre>
+       * The creation time for the cookie in microseconds since
+       * Windows epoch - 1/1/1601 UTC.
+       * </pre>
+       *
+       * <code>optional int64 creation_time_us = 5;</code>
+       */
+      public Builder setCreationTimeUs(long value) {
+        copyOnWrite();
+        instance.setCreationTimeUs(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * The creation time for the cookie in microseconds since
+       * Windows epoch - 1/1/1601 UTC.
+       * </pre>
+       *
+       * <code>optional int64 creation_time_us = 5;</code>
+       */
+      public Builder clearCreationTimeUs() {
+        copyOnWrite();
+        instance.clearCreationTimeUs();
+        return this;
+      }
+
+      /**
+       * <pre>
+       * The expiration time for the cookie in microseconds since
+       * Windows epoch - 1/1/1601 UTC.
+       * </pre>
+       *
+       * <code>optional int64 expiration_time_us = 6;</code>
+       */
+      public long getExpirationTimeUs() {
+        return instance.getExpirationTimeUs();
+      }
+      /**
+       * <pre>
+       * The expiration time for the cookie in microseconds since
+       * Windows epoch - 1/1/1601 UTC.
+       * </pre>
+       *
+       * <code>optional int64 expiration_time_us = 6;</code>
+       */
+      public Builder setExpirationTimeUs(long value) {
+        copyOnWrite();
+        instance.setExpirationTimeUs(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * The expiration time for the cookie in microseconds since
+       * Windows epoch - 1/1/1601 UTC.
+       * </pre>
+       *
+       * <code>optional int64 expiration_time_us = 6;</code>
+       */
+      public Builder clearExpirationTimeUs() {
+        copyOnWrite();
+        instance.clearExpirationTimeUs();
+        return this;
+      }
+
+      /**
+       * <pre>
+       * The last access time in microseconds since
+       * Windows epoch - 1/1/1601 UTC.
+       * </pre>
+       *
+       * <code>optional int64 last_access_time_us = 7;</code>
+       */
+      public long getLastAccessTimeUs() {
+        return instance.getLastAccessTimeUs();
+      }
+      /**
+       * <pre>
+       * The last access time in microseconds since
+       * Windows epoch - 1/1/1601 UTC.
+       * </pre>
+       *
+       * <code>optional int64 last_access_time_us = 7;</code>
+       */
+      public Builder setLastAccessTimeUs(long value) {
+        copyOnWrite();
+        instance.setLastAccessTimeUs(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * The last access time in microseconds since
+       * Windows epoch - 1/1/1601 UTC.
+       * </pre>
+       *
+       * <code>optional int64 last_access_time_us = 7;</code>
+       */
+      public Builder clearLastAccessTimeUs() {
+        copyOnWrite();
+        instance.clearLastAccessTimeUs();
+        return this;
+      }
+
+      /**
+       * <pre>
+       * Whether the cookie should be transmitted only over secure connection.
+       * Defaults to false.
+       * </pre>
+       *
+       * <code>optional bool secure = 8;</code>
+       */
+      public boolean getSecure() {
+        return instance.getSecure();
+      }
+      /**
+       * <pre>
+       * Whether the cookie should be transmitted only over secure connection.
+       * Defaults to false.
+       * </pre>
+       *
+       * <code>optional bool secure = 8;</code>
+       */
+      public Builder setSecure(boolean value) {
+        copyOnWrite();
+        instance.setSecure(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * Whether the cookie should be transmitted only over secure connection.
+       * Defaults to false.
+       * </pre>
+       *
+       * <code>optional bool secure = 8;</code>
+       */
+      public Builder clearSecure() {
+        copyOnWrite();
+        instance.clearSecure();
+        return this;
+      }
+
+      /**
+       * <pre>
+       * Whether this is an HTTP-only cookie. Defaults to false.
+       * </pre>
+       *
+       * <code>optional bool http_only = 9;</code>
+       */
+      public boolean getHttpOnly() {
+        return instance.getHttpOnly();
+      }
+      /**
+       * <pre>
+       * Whether this is an HTTP-only cookie. Defaults to false.
+       * </pre>
+       *
+       * <code>optional bool http_only = 9;</code>
+       */
+      public Builder setHttpOnly(boolean value) {
+        copyOnWrite();
+        instance.setHttpOnly(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * Whether this is an HTTP-only cookie. Defaults to false.
+       * </pre>
+       *
+       * <code>optional bool http_only = 9;</code>
+       */
+      public Builder clearHttpOnly() {
+        copyOnWrite();
+        instance.clearHttpOnly();
+        return this;
+      }
+
+      // @@protoc_insertion_point(builder_scope:cobalt.storage.Cookie)
+    }
+    protected final Object dynamicMethod(
+        com.google.protobuf.GeneratedMessageLite.MethodToInvoke method,
+        Object arg0, Object arg1) {
+      switch (method) {
+        case NEW_MUTABLE_INSTANCE: {
+          return new dev.cobalt.storage.StorageProto.Cookie();
+        }
+        case IS_INITIALIZED: {
+          return DEFAULT_INSTANCE;
+        }
+        case MAKE_IMMUTABLE: {
+          return null;
+        }
+        case NEW_BUILDER: {
+          return new Builder();
+        }
+        case VISIT: {
+          Visitor visitor = (Visitor) arg0;
+          dev.cobalt.storage.StorageProto.Cookie other = (dev.cobalt.storage.StorageProto.Cookie) arg1;
+          name_ = visitor.visitString(!name_.isEmpty(), name_,
+              !other.name_.isEmpty(), other.name_);
+          value_ = visitor.visitString(!value_.isEmpty(), value_,
+              !other.value_.isEmpty(), other.value_);
+          domain_ = visitor.visitString(!domain_.isEmpty(), domain_,
+              !other.domain_.isEmpty(), other.domain_);
+          path_ = visitor.visitString(!path_.isEmpty(), path_,
+              !other.path_.isEmpty(), other.path_);
+          creationTimeUs_ = visitor.visitLong(creationTimeUs_ != 0L, creationTimeUs_,
+              other.creationTimeUs_ != 0L, other.creationTimeUs_);
+          expirationTimeUs_ = visitor.visitLong(expirationTimeUs_ != 0L, expirationTimeUs_,
+              other.expirationTimeUs_ != 0L, other.expirationTimeUs_);
+          lastAccessTimeUs_ = visitor.visitLong(lastAccessTimeUs_ != 0L, lastAccessTimeUs_,
+              other.lastAccessTimeUs_ != 0L, other.lastAccessTimeUs_);
+          secure_ = visitor.visitBoolean(secure_ != false, secure_,
+              other.secure_ != false, other.secure_);
+          httpOnly_ = visitor.visitBoolean(httpOnly_ != false, httpOnly_,
+              other.httpOnly_ != false, other.httpOnly_);
+          if (visitor == com.google.protobuf.GeneratedMessageLite.MergeFromVisitor
+              .INSTANCE) {
+          }
+          return this;
+        }
+        case MERGE_FROM_STREAM: {
+          com.google.protobuf.CodedInputStream input =
+              (com.google.protobuf.CodedInputStream) arg0;
+          com.google.protobuf.ExtensionRegistryLite extensionRegistry =
+              (com.google.protobuf.ExtensionRegistryLite) arg1;
+          try {
+            boolean done = false;
+            while (!done) {
+              int tag = input.readTag();
+              switch (tag) {
+                case 0:
+                  done = true;
+                  break;
+                default: {
+                  if (!input.skipField(tag)) {
+                    done = true;
+                  }
+                  break;
+                }
+                case 10: {
+                  String s = input.readStringRequireUtf8();
+
+                  name_ = s;
+                  break;
+                }
+                case 18: {
+                  String s = input.readStringRequireUtf8();
+
+                  value_ = s;
+                  break;
+                }
+                case 26: {
+                  String s = input.readStringRequireUtf8();
+
+                  domain_ = s;
+                  break;
+                }
+                case 34: {
+                  String s = input.readStringRequireUtf8();
+
+                  path_ = s;
+                  break;
+                }
+                case 40: {
+
+                  creationTimeUs_ = input.readInt64();
+                  break;
+                }
+                case 48: {
+
+                  expirationTimeUs_ = input.readInt64();
+                  break;
+                }
+                case 56: {
+
+                  lastAccessTimeUs_ = input.readInt64();
+                  break;
+                }
+                case 64: {
+
+                  secure_ = input.readBool();
+                  break;
+                }
+                case 72: {
+
+                  httpOnly_ = input.readBool();
+                  break;
+                }
+              }
+            }
+          } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+            throw new RuntimeException(e.setUnfinishedMessage(this));
+          } catch (java.io.IOException e) {
+            throw new RuntimeException(
+                new com.google.protobuf.InvalidProtocolBufferException(
+                    e.getMessage()).setUnfinishedMessage(this));
+          } finally {
+          }
+        }
+        case GET_DEFAULT_INSTANCE: {
+          return DEFAULT_INSTANCE;
+        }
+        case GET_PARSER: {
+          if (PARSER == null) {    synchronized (dev.cobalt.storage.StorageProto.Cookie.class) {
+              if (PARSER == null) {
+                PARSER = new DefaultInstanceBasedParser(DEFAULT_INSTANCE);
+              }
+            }
+          }
+          return PARSER;
+        }
+      }
+      throw new UnsupportedOperationException();
+    }
+
+
+    // @@protoc_insertion_point(class_scope:cobalt.storage.Cookie)
+    private static final dev.cobalt.storage.StorageProto.Cookie DEFAULT_INSTANCE;
+    static {
+      DEFAULT_INSTANCE = new Cookie();
+      DEFAULT_INSTANCE.makeImmutable();
+    }
+
+    public static dev.cobalt.storage.StorageProto.Cookie getDefaultInstance() {
+      return DEFAULT_INSTANCE;
+    }
+
+    private static volatile com.google.protobuf.Parser<Cookie> PARSER;
+
+    public static com.google.protobuf.Parser<Cookie> parser() {
+      return DEFAULT_INSTANCE.getParserForType();
+    }
+  }
+
+  public interface LocalStorageEntryOrBuilder extends
+      // @@protoc_insertion_point(interface_extends:cobalt.storage.LocalStorageEntry)
+      com.google.protobuf.MessageLiteOrBuilder {
+
+    /**
+     * <pre>
+     * The key for the local storage entry.
+     * </pre>
+     *
+     * <code>optional string key = 1;</code>
+     */
+    java.lang.String getKey();
+    /**
+     * <pre>
+     * The key for the local storage entry.
+     * </pre>
+     *
+     * <code>optional string key = 1;</code>
+     */
+    com.google.protobuf.ByteString
+        getKeyBytes();
+
+    /**
+     * <pre>
+     * The value of the local storage entry.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    java.lang.String getValue();
+    /**
+     * <pre>
+     * The value of the local storage entry.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    com.google.protobuf.ByteString
+        getValueBytes();
+  }
+  /**
+   * <pre>
+   * A single local storage entry.
+   * </pre>
+   *
+   * Protobuf type {@code cobalt.storage.LocalStorageEntry}
+   */
+  public  static final class LocalStorageEntry extends
+      com.google.protobuf.GeneratedMessageLite<
+          LocalStorageEntry, LocalStorageEntry.Builder> implements
+      // @@protoc_insertion_point(message_implements:cobalt.storage.LocalStorageEntry)
+      LocalStorageEntryOrBuilder {
+    private LocalStorageEntry() {
+      key_ = "";
+      value_ = "";
+    }
+    public static final int KEY_FIELD_NUMBER = 1;
+    private java.lang.String key_;
+    /**
+     * <pre>
+     * The key for the local storage entry.
+     * </pre>
+     *
+     * <code>optional string key = 1;</code>
+     */
+    public java.lang.String getKey() {
+      return key_;
+    }
+    /**
+     * <pre>
+     * The key for the local storage entry.
+     * </pre>
+     *
+     * <code>optional string key = 1;</code>
+     */
+    public com.google.protobuf.ByteString
+        getKeyBytes() {
+      return com.google.protobuf.ByteString.copyFromUtf8(key_);
+    }
+    /**
+     * <pre>
+     * The key for the local storage entry.
+     * </pre>
+     *
+     * <code>optional string key = 1;</code>
+     */
+    private void setKey(
+        java.lang.String value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  
+      key_ = value;
+    }
+    /**
+     * <pre>
+     * The key for the local storage entry.
+     * </pre>
+     *
+     * <code>optional string key = 1;</code>
+     */
+    private void clearKey() {
+      
+      key_ = getDefaultInstance().getKey();
+    }
+    /**
+     * <pre>
+     * The key for the local storage entry.
+     * </pre>
+     *
+     * <code>optional string key = 1;</code>
+     */
+    private void setKeyBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  checkByteStringIsUtf8(value);
+      
+      key_ = value.toStringUtf8();
+    }
+
+    public static final int VALUE_FIELD_NUMBER = 2;
+    private java.lang.String value_;
+    /**
+     * <pre>
+     * The value of the local storage entry.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    public java.lang.String getValue() {
+      return value_;
+    }
+    /**
+     * <pre>
+     * The value of the local storage entry.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    public com.google.protobuf.ByteString
+        getValueBytes() {
+      return com.google.protobuf.ByteString.copyFromUtf8(value_);
+    }
+    /**
+     * <pre>
+     * The value of the local storage entry.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    private void setValue(
+        java.lang.String value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  
+      value_ = value;
+    }
+    /**
+     * <pre>
+     * The value of the local storage entry.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    private void clearValue() {
+      
+      value_ = getDefaultInstance().getValue();
+    }
+    /**
+     * <pre>
+     * The value of the local storage entry.
+     * </pre>
+     *
+     * <code>optional string value = 2;</code>
+     */
+    private void setValueBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  checkByteStringIsUtf8(value);
+      
+      value_ = value.toStringUtf8();
+    }
+
+    public void writeTo(com.google.protobuf.CodedOutputStream output)
+                        throws java.io.IOException {
+      if (!key_.isEmpty()) {
+        output.writeString(1, getKey());
+      }
+      if (!value_.isEmpty()) {
+        output.writeString(2, getValue());
+      }
+    }
+
+    public int getSerializedSize() {
+      int size = memoizedSerializedSize;
+      if (size != -1) return size;
+
+      size = 0;
+      if (!key_.isEmpty()) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeStringSize(1, getKey());
+      }
+      if (!value_.isEmpty()) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeStringSize(2, getValue());
+      }
+      memoizedSerializedSize = size;
+      return size;
+    }
+
+    public static dev.cobalt.storage.StorageProto.LocalStorageEntry parseFrom(
+        com.google.protobuf.ByteString data)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorageEntry parseFrom(
+        com.google.protobuf.ByteString data,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorageEntry parseFrom(byte[] data)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorageEntry parseFrom(
+        byte[] data,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorageEntry parseFrom(java.io.InputStream input)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorageEntry parseFrom(
+        java.io.InputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorageEntry parseDelimitedFrom(java.io.InputStream input)
+        throws java.io.IOException {
+      return parseDelimitedFrom(DEFAULT_INSTANCE, input);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorageEntry parseDelimitedFrom(
+        java.io.InputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      return parseDelimitedFrom(DEFAULT_INSTANCE, input, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorageEntry parseFrom(
+        com.google.protobuf.CodedInputStream input)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorageEntry parseFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input, extensionRegistry);
+    }
+
+    public static Builder newBuilder() {
+      return DEFAULT_INSTANCE.toBuilder();
+    }
+    public static Builder newBuilder(dev.cobalt.storage.StorageProto.LocalStorageEntry prototype) {
+      return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+    }
+
+    /**
+     * <pre>
+     * A single local storage entry.
+     * </pre>
+     *
+     * Protobuf type {@code cobalt.storage.LocalStorageEntry}
+     */
+    public static final class Builder extends
+        com.google.protobuf.GeneratedMessageLite.Builder<
+          dev.cobalt.storage.StorageProto.LocalStorageEntry, Builder> implements
+        // @@protoc_insertion_point(builder_implements:cobalt.storage.LocalStorageEntry)
+        dev.cobalt.storage.StorageProto.LocalStorageEntryOrBuilder {
+      // Construct using dev.cobalt.storage.StorageProto.LocalStorageEntry.newBuilder()
+      private Builder() {
+        super(DEFAULT_INSTANCE);
+      }
+
+
+      /**
+       * <pre>
+       * The key for the local storage entry.
+       * </pre>
+       *
+       * <code>optional string key = 1;</code>
+       */
+      public java.lang.String getKey() {
+        return instance.getKey();
+      }
+      /**
+       * <pre>
+       * The key for the local storage entry.
+       * </pre>
+       *
+       * <code>optional string key = 1;</code>
+       */
+      public com.google.protobuf.ByteString
+          getKeyBytes() {
+        return instance.getKeyBytes();
+      }
+      /**
+       * <pre>
+       * The key for the local storage entry.
+       * </pre>
+       *
+       * <code>optional string key = 1;</code>
+       */
+      public Builder setKey(
+          java.lang.String value) {
+        copyOnWrite();
+        instance.setKey(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * The key for the local storage entry.
+       * </pre>
+       *
+       * <code>optional string key = 1;</code>
+       */
+      public Builder clearKey() {
+        copyOnWrite();
+        instance.clearKey();
+        return this;
+      }
+      /**
+       * <pre>
+       * The key for the local storage entry.
+       * </pre>
+       *
+       * <code>optional string key = 1;</code>
+       */
+      public Builder setKeyBytes(
+          com.google.protobuf.ByteString value) {
+        copyOnWrite();
+        instance.setKeyBytes(value);
+        return this;
+      }
+
+      /**
+       * <pre>
+       * The value of the local storage entry.
+       * </pre>
+       *
+       * <code>optional string value = 2;</code>
+       */
+      public java.lang.String getValue() {
+        return instance.getValue();
+      }
+      /**
+       * <pre>
+       * The value of the local storage entry.
+       * </pre>
+       *
+       * <code>optional string value = 2;</code>
+       */
+      public com.google.protobuf.ByteString
+          getValueBytes() {
+        return instance.getValueBytes();
+      }
+      /**
+       * <pre>
+       * The value of the local storage entry.
+       * </pre>
+       *
+       * <code>optional string value = 2;</code>
+       */
+      public Builder setValue(
+          java.lang.String value) {
+        copyOnWrite();
+        instance.setValue(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * The value of the local storage entry.
+       * </pre>
+       *
+       * <code>optional string value = 2;</code>
+       */
+      public Builder clearValue() {
+        copyOnWrite();
+        instance.clearValue();
+        return this;
+      }
+      /**
+       * <pre>
+       * The value of the local storage entry.
+       * </pre>
+       *
+       * <code>optional string value = 2;</code>
+       */
+      public Builder setValueBytes(
+          com.google.protobuf.ByteString value) {
+        copyOnWrite();
+        instance.setValueBytes(value);
+        return this;
+      }
+
+      // @@protoc_insertion_point(builder_scope:cobalt.storage.LocalStorageEntry)
+    }
+    protected final Object dynamicMethod(
+        com.google.protobuf.GeneratedMessageLite.MethodToInvoke method,
+        Object arg0, Object arg1) {
+      switch (method) {
+        case NEW_MUTABLE_INSTANCE: {
+          return new dev.cobalt.storage.StorageProto.LocalStorageEntry();
+        }
+        case IS_INITIALIZED: {
+          return DEFAULT_INSTANCE;
+        }
+        case MAKE_IMMUTABLE: {
+          return null;
+        }
+        case NEW_BUILDER: {
+          return new Builder();
+        }
+        case VISIT: {
+          Visitor visitor = (Visitor) arg0;
+          dev.cobalt.storage.StorageProto.LocalStorageEntry other = (dev.cobalt.storage.StorageProto.LocalStorageEntry) arg1;
+          key_ = visitor.visitString(!key_.isEmpty(), key_,
+              !other.key_.isEmpty(), other.key_);
+          value_ = visitor.visitString(!value_.isEmpty(), value_,
+              !other.value_.isEmpty(), other.value_);
+          if (visitor == com.google.protobuf.GeneratedMessageLite.MergeFromVisitor
+              .INSTANCE) {
+          }
+          return this;
+        }
+        case MERGE_FROM_STREAM: {
+          com.google.protobuf.CodedInputStream input =
+              (com.google.protobuf.CodedInputStream) arg0;
+          com.google.protobuf.ExtensionRegistryLite extensionRegistry =
+              (com.google.protobuf.ExtensionRegistryLite) arg1;
+          try {
+            boolean done = false;
+            while (!done) {
+              int tag = input.readTag();
+              switch (tag) {
+                case 0:
+                  done = true;
+                  break;
+                default: {
+                  if (!input.skipField(tag)) {
+                    done = true;
+                  }
+                  break;
+                }
+                case 10: {
+                  String s = input.readStringRequireUtf8();
+
+                  key_ = s;
+                  break;
+                }
+                case 18: {
+                  String s = input.readStringRequireUtf8();
+
+                  value_ = s;
+                  break;
+                }
+              }
+            }
+          } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+            throw new RuntimeException(e.setUnfinishedMessage(this));
+          } catch (java.io.IOException e) {
+            throw new RuntimeException(
+                new com.google.protobuf.InvalidProtocolBufferException(
+                    e.getMessage()).setUnfinishedMessage(this));
+          } finally {
+          }
+        }
+        case GET_DEFAULT_INSTANCE: {
+          return DEFAULT_INSTANCE;
+        }
+        case GET_PARSER: {
+          if (PARSER == null) {    synchronized (dev.cobalt.storage.StorageProto.LocalStorageEntry.class) {
+              if (PARSER == null) {
+                PARSER = new DefaultInstanceBasedParser(DEFAULT_INSTANCE);
+              }
+            }
+          }
+          return PARSER;
+        }
+      }
+      throw new UnsupportedOperationException();
+    }
+
+
+    // @@protoc_insertion_point(class_scope:cobalt.storage.LocalStorageEntry)
+    private static final dev.cobalt.storage.StorageProto.LocalStorageEntry DEFAULT_INSTANCE;
+    static {
+      DEFAULT_INSTANCE = new LocalStorageEntry();
+      DEFAULT_INSTANCE.makeImmutable();
+    }
+
+    public static dev.cobalt.storage.StorageProto.LocalStorageEntry getDefaultInstance() {
+      return DEFAULT_INSTANCE;
+    }
+
+    private static volatile com.google.protobuf.Parser<LocalStorageEntry> PARSER;
+
+    public static com.google.protobuf.Parser<LocalStorageEntry> parser() {
+      return DEFAULT_INSTANCE.getParserForType();
+    }
+  }
+
+  public interface LocalStorageOrBuilder extends
+      // @@protoc_insertion_point(interface_extends:cobalt.storage.LocalStorage)
+      com.google.protobuf.MessageLiteOrBuilder {
+
+    /**
+     * <pre>
+     * A serialzied origin as defined in:
+     * https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin.
+     * For example: "https://www.youtube.com"
+     * </pre>
+     *
+     * <code>optional string serialized_origin = 1;</code>
+     */
+    java.lang.String getSerializedOrigin();
+    /**
+     * <pre>
+     * A serialzied origin as defined in:
+     * https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin.
+     * For example: "https://www.youtube.com"
+     * </pre>
+     *
+     * <code>optional string serialized_origin = 1;</code>
+     */
+    com.google.protobuf.ByteString
+        getSerializedOriginBytes();
+
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    java.util.List<dev.cobalt.storage.StorageProto.LocalStorageEntry> 
+        getLocalStorageEntriesList();
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    dev.cobalt.storage.StorageProto.LocalStorageEntry getLocalStorageEntries(int index);
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    int getLocalStorageEntriesCount();
+  }
+  /**
+   * <pre>
+   * Multiple local storages identified by unique id.
+   * </pre>
+   *
+   * Protobuf type {@code cobalt.storage.LocalStorage}
+   */
+  public  static final class LocalStorage extends
+      com.google.protobuf.GeneratedMessageLite<
+          LocalStorage, LocalStorage.Builder> implements
+      // @@protoc_insertion_point(message_implements:cobalt.storage.LocalStorage)
+      LocalStorageOrBuilder {
+    private LocalStorage() {
+      serializedOrigin_ = "";
+      localStorageEntries_ = emptyProtobufList();
+    }
+    private int bitField0_;
+    public static final int SERIALIZED_ORIGIN_FIELD_NUMBER = 1;
+    private java.lang.String serializedOrigin_;
+    /**
+     * <pre>
+     * A serialzied origin as defined in:
+     * https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin.
+     * For example: "https://www.youtube.com"
+     * </pre>
+     *
+     * <code>optional string serialized_origin = 1;</code>
+     */
+    public java.lang.String getSerializedOrigin() {
+      return serializedOrigin_;
+    }
+    /**
+     * <pre>
+     * A serialzied origin as defined in:
+     * https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin.
+     * For example: "https://www.youtube.com"
+     * </pre>
+     *
+     * <code>optional string serialized_origin = 1;</code>
+     */
+    public com.google.protobuf.ByteString
+        getSerializedOriginBytes() {
+      return com.google.protobuf.ByteString.copyFromUtf8(serializedOrigin_);
+    }
+    /**
+     * <pre>
+     * A serialzied origin as defined in:
+     * https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin.
+     * For example: "https://www.youtube.com"
+     * </pre>
+     *
+     * <code>optional string serialized_origin = 1;</code>
+     */
+    private void setSerializedOrigin(
+        java.lang.String value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  
+      serializedOrigin_ = value;
+    }
+    /**
+     * <pre>
+     * A serialzied origin as defined in:
+     * https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin.
+     * For example: "https://www.youtube.com"
+     * </pre>
+     *
+     * <code>optional string serialized_origin = 1;</code>
+     */
+    private void clearSerializedOrigin() {
+      
+      serializedOrigin_ = getDefaultInstance().getSerializedOrigin();
+    }
+    /**
+     * <pre>
+     * A serialzied origin as defined in:
+     * https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin.
+     * For example: "https://www.youtube.com"
+     * </pre>
+     *
+     * <code>optional string serialized_origin = 1;</code>
+     */
+    private void setSerializedOriginBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) {
+    throw new NullPointerException();
+  }
+  checkByteStringIsUtf8(value);
+      
+      serializedOrigin_ = value.toStringUtf8();
+    }
+
+    public static final int LOCAL_STORAGE_ENTRIES_FIELD_NUMBER = 2;
+    private com.google.protobuf.Internal.ProtobufList<dev.cobalt.storage.StorageProto.LocalStorageEntry> localStorageEntries_;
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    public java.util.List<dev.cobalt.storage.StorageProto.LocalStorageEntry> getLocalStorageEntriesList() {
+      return localStorageEntries_;
+    }
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    public java.util.List<? extends dev.cobalt.storage.StorageProto.LocalStorageEntryOrBuilder> 
+        getLocalStorageEntriesOrBuilderList() {
+      return localStorageEntries_;
+    }
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    public int getLocalStorageEntriesCount() {
+      return localStorageEntries_.size();
+    }
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    public dev.cobalt.storage.StorageProto.LocalStorageEntry getLocalStorageEntries(int index) {
+      return localStorageEntries_.get(index);
+    }
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    public dev.cobalt.storage.StorageProto.LocalStorageEntryOrBuilder getLocalStorageEntriesOrBuilder(
+        int index) {
+      return localStorageEntries_.get(index);
+    }
+    private void ensureLocalStorageEntriesIsMutable() {
+      if (!localStorageEntries_.isModifiable()) {
+        localStorageEntries_ =
+            com.google.protobuf.GeneratedMessageLite.mutableCopy(localStorageEntries_);
+       }
+    }
+
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    private void setLocalStorageEntries(
+        int index, dev.cobalt.storage.StorageProto.LocalStorageEntry value) {
+      if (value == null) {
+        throw new NullPointerException();
+      }
+      ensureLocalStorageEntriesIsMutable();
+      localStorageEntries_.set(index, value);
+    }
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    private void setLocalStorageEntries(
+        int index, dev.cobalt.storage.StorageProto.LocalStorageEntry.Builder builderForValue) {
+      ensureLocalStorageEntriesIsMutable();
+      localStorageEntries_.set(index, builderForValue.build());
+    }
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    private void addLocalStorageEntries(dev.cobalt.storage.StorageProto.LocalStorageEntry value) {
+      if (value == null) {
+        throw new NullPointerException();
+      }
+      ensureLocalStorageEntriesIsMutable();
+      localStorageEntries_.add(value);
+    }
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    private void addLocalStorageEntries(
+        int index, dev.cobalt.storage.StorageProto.LocalStorageEntry value) {
+      if (value == null) {
+        throw new NullPointerException();
+      }
+      ensureLocalStorageEntriesIsMutable();
+      localStorageEntries_.add(index, value);
+    }
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    private void addLocalStorageEntries(
+        dev.cobalt.storage.StorageProto.LocalStorageEntry.Builder builderForValue) {
+      ensureLocalStorageEntriesIsMutable();
+      localStorageEntries_.add(builderForValue.build());
+    }
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    private void addLocalStorageEntries(
+        int index, dev.cobalt.storage.StorageProto.LocalStorageEntry.Builder builderForValue) {
+      ensureLocalStorageEntriesIsMutable();
+      localStorageEntries_.add(index, builderForValue.build());
+    }
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    private void addAllLocalStorageEntries(
+        java.lang.Iterable<? extends dev.cobalt.storage.StorageProto.LocalStorageEntry> values) {
+      ensureLocalStorageEntriesIsMutable();
+      com.google.protobuf.AbstractMessageLite.addAll(
+          values, localStorageEntries_);
+    }
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    private void clearLocalStorageEntries() {
+      localStorageEntries_ = emptyProtobufList();
+    }
+    /**
+     * <pre>
+     * The local storage entries for individual local storage.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+     */
+    private void removeLocalStorageEntries(int index) {
+      ensureLocalStorageEntriesIsMutable();
+      localStorageEntries_.remove(index);
+    }
+
+    public void writeTo(com.google.protobuf.CodedOutputStream output)
+                        throws java.io.IOException {
+      if (!serializedOrigin_.isEmpty()) {
+        output.writeString(1, getSerializedOrigin());
+      }
+      for (int i = 0; i < localStorageEntries_.size(); i++) {
+        output.writeMessage(2, localStorageEntries_.get(i));
+      }
+    }
+
+    public int getSerializedSize() {
+      int size = memoizedSerializedSize;
+      if (size != -1) return size;
+
+      size = 0;
+      if (!serializedOrigin_.isEmpty()) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeStringSize(1, getSerializedOrigin());
+      }
+      for (int i = 0; i < localStorageEntries_.size(); i++) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeMessageSize(2, localStorageEntries_.get(i));
+      }
+      memoizedSerializedSize = size;
+      return size;
+    }
+
+    public static dev.cobalt.storage.StorageProto.LocalStorage parseFrom(
+        com.google.protobuf.ByteString data)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorage parseFrom(
+        com.google.protobuf.ByteString data,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorage parseFrom(byte[] data)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorage parseFrom(
+        byte[] data,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorage parseFrom(java.io.InputStream input)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorage parseFrom(
+        java.io.InputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorage parseDelimitedFrom(java.io.InputStream input)
+        throws java.io.IOException {
+      return parseDelimitedFrom(DEFAULT_INSTANCE, input);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorage parseDelimitedFrom(
+        java.io.InputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      return parseDelimitedFrom(DEFAULT_INSTANCE, input, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorage parseFrom(
+        com.google.protobuf.CodedInputStream input)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input);
+    }
+    public static dev.cobalt.storage.StorageProto.LocalStorage parseFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input, extensionRegistry);
+    }
+
+    public static Builder newBuilder() {
+      return DEFAULT_INSTANCE.toBuilder();
+    }
+    public static Builder newBuilder(dev.cobalt.storage.StorageProto.LocalStorage prototype) {
+      return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+    }
+
+    /**
+     * <pre>
+     * Multiple local storages identified by unique id.
+     * </pre>
+     *
+     * Protobuf type {@code cobalt.storage.LocalStorage}
+     */
+    public static final class Builder extends
+        com.google.protobuf.GeneratedMessageLite.Builder<
+          dev.cobalt.storage.StorageProto.LocalStorage, Builder> implements
+        // @@protoc_insertion_point(builder_implements:cobalt.storage.LocalStorage)
+        dev.cobalt.storage.StorageProto.LocalStorageOrBuilder {
+      // Construct using dev.cobalt.storage.StorageProto.LocalStorage.newBuilder()
+      private Builder() {
+        super(DEFAULT_INSTANCE);
+      }
+
+
+      /**
+       * <pre>
+       * A serialzied origin as defined in:
+       * https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin.
+       * For example: "https://www.youtube.com"
+       * </pre>
+       *
+       * <code>optional string serialized_origin = 1;</code>
+       */
+      public java.lang.String getSerializedOrigin() {
+        return instance.getSerializedOrigin();
+      }
+      /**
+       * <pre>
+       * A serialzied origin as defined in:
+       * https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin.
+       * For example: "https://www.youtube.com"
+       * </pre>
+       *
+       * <code>optional string serialized_origin = 1;</code>
+       */
+      public com.google.protobuf.ByteString
+          getSerializedOriginBytes() {
+        return instance.getSerializedOriginBytes();
+      }
+      /**
+       * <pre>
+       * A serialzied origin as defined in:
+       * https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin.
+       * For example: "https://www.youtube.com"
+       * </pre>
+       *
+       * <code>optional string serialized_origin = 1;</code>
+       */
+      public Builder setSerializedOrigin(
+          java.lang.String value) {
+        copyOnWrite();
+        instance.setSerializedOrigin(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * A serialzied origin as defined in:
+       * https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin.
+       * For example: "https://www.youtube.com"
+       * </pre>
+       *
+       * <code>optional string serialized_origin = 1;</code>
+       */
+      public Builder clearSerializedOrigin() {
+        copyOnWrite();
+        instance.clearSerializedOrigin();
+        return this;
+      }
+      /**
+       * <pre>
+       * A serialzied origin as defined in:
+       * https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin.
+       * For example: "https://www.youtube.com"
+       * </pre>
+       *
+       * <code>optional string serialized_origin = 1;</code>
+       */
+      public Builder setSerializedOriginBytes(
+          com.google.protobuf.ByteString value) {
+        copyOnWrite();
+        instance.setSerializedOriginBytes(value);
+        return this;
+      }
+
+      /**
+       * <pre>
+       * The local storage entries for individual local storage.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+       */
+      public java.util.List<dev.cobalt.storage.StorageProto.LocalStorageEntry> getLocalStorageEntriesList() {
+        return java.util.Collections.unmodifiableList(
+            instance.getLocalStorageEntriesList());
+      }
+      /**
+       * <pre>
+       * The local storage entries for individual local storage.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+       */
+      public int getLocalStorageEntriesCount() {
+        return instance.getLocalStorageEntriesCount();
+      }/**
+       * <pre>
+       * The local storage entries for individual local storage.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+       */
+      public dev.cobalt.storage.StorageProto.LocalStorageEntry getLocalStorageEntries(int index) {
+        return instance.getLocalStorageEntries(index);
+      }
+      /**
+       * <pre>
+       * The local storage entries for individual local storage.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+       */
+      public Builder setLocalStorageEntries(
+          int index, dev.cobalt.storage.StorageProto.LocalStorageEntry value) {
+        copyOnWrite();
+        instance.setLocalStorageEntries(index, value);
+        return this;
+      }
+      /**
+       * <pre>
+       * The local storage entries for individual local storage.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+       */
+      public Builder setLocalStorageEntries(
+          int index, dev.cobalt.storage.StorageProto.LocalStorageEntry.Builder builderForValue) {
+        copyOnWrite();
+        instance.setLocalStorageEntries(index, builderForValue);
+        return this;
+      }
+      /**
+       * <pre>
+       * The local storage entries for individual local storage.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+       */
+      public Builder addLocalStorageEntries(dev.cobalt.storage.StorageProto.LocalStorageEntry value) {
+        copyOnWrite();
+        instance.addLocalStorageEntries(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * The local storage entries for individual local storage.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+       */
+      public Builder addLocalStorageEntries(
+          int index, dev.cobalt.storage.StorageProto.LocalStorageEntry value) {
+        copyOnWrite();
+        instance.addLocalStorageEntries(index, value);
+        return this;
+      }
+      /**
+       * <pre>
+       * The local storage entries for individual local storage.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+       */
+      public Builder addLocalStorageEntries(
+          dev.cobalt.storage.StorageProto.LocalStorageEntry.Builder builderForValue) {
+        copyOnWrite();
+        instance.addLocalStorageEntries(builderForValue);
+        return this;
+      }
+      /**
+       * <pre>
+       * The local storage entries for individual local storage.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+       */
+      public Builder addLocalStorageEntries(
+          int index, dev.cobalt.storage.StorageProto.LocalStorageEntry.Builder builderForValue) {
+        copyOnWrite();
+        instance.addLocalStorageEntries(index, builderForValue);
+        return this;
+      }
+      /**
+       * <pre>
+       * The local storage entries for individual local storage.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+       */
+      public Builder addAllLocalStorageEntries(
+          java.lang.Iterable<? extends dev.cobalt.storage.StorageProto.LocalStorageEntry> values) {
+        copyOnWrite();
+        instance.addAllLocalStorageEntries(values);
+        return this;
+      }
+      /**
+       * <pre>
+       * The local storage entries for individual local storage.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+       */
+      public Builder clearLocalStorageEntries() {
+        copyOnWrite();
+        instance.clearLocalStorageEntries();
+        return this;
+      }
+      /**
+       * <pre>
+       * The local storage entries for individual local storage.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorageEntry local_storage_entries = 2;</code>
+       */
+      public Builder removeLocalStorageEntries(int index) {
+        copyOnWrite();
+        instance.removeLocalStorageEntries(index);
+        return this;
+      }
+
+      // @@protoc_insertion_point(builder_scope:cobalt.storage.LocalStorage)
+    }
+    protected final Object dynamicMethod(
+        com.google.protobuf.GeneratedMessageLite.MethodToInvoke method,
+        Object arg0, Object arg1) {
+      switch (method) {
+        case NEW_MUTABLE_INSTANCE: {
+          return new dev.cobalt.storage.StorageProto.LocalStorage();
+        }
+        case IS_INITIALIZED: {
+          return DEFAULT_INSTANCE;
+        }
+        case MAKE_IMMUTABLE: {
+          localStorageEntries_.makeImmutable();
+          return null;
+        }
+        case NEW_BUILDER: {
+          return new Builder();
+        }
+        case VISIT: {
+          Visitor visitor = (Visitor) arg0;
+          dev.cobalt.storage.StorageProto.LocalStorage other = (dev.cobalt.storage.StorageProto.LocalStorage) arg1;
+          serializedOrigin_ = visitor.visitString(!serializedOrigin_.isEmpty(), serializedOrigin_,
+              !other.serializedOrigin_.isEmpty(), other.serializedOrigin_);
+          localStorageEntries_= visitor.visitList(localStorageEntries_, other.localStorageEntries_);
+          if (visitor == com.google.protobuf.GeneratedMessageLite.MergeFromVisitor
+              .INSTANCE) {
+            bitField0_ |= other.bitField0_;
+          }
+          return this;
+        }
+        case MERGE_FROM_STREAM: {
+          com.google.protobuf.CodedInputStream input =
+              (com.google.protobuf.CodedInputStream) arg0;
+          com.google.protobuf.ExtensionRegistryLite extensionRegistry =
+              (com.google.protobuf.ExtensionRegistryLite) arg1;
+          try {
+            boolean done = false;
+            while (!done) {
+              int tag = input.readTag();
+              switch (tag) {
+                case 0:
+                  done = true;
+                  break;
+                default: {
+                  if (!input.skipField(tag)) {
+                    done = true;
+                  }
+                  break;
+                }
+                case 10: {
+                  String s = input.readStringRequireUtf8();
+
+                  serializedOrigin_ = s;
+                  break;
+                }
+                case 18: {
+                  if (!localStorageEntries_.isModifiable()) {
+                    localStorageEntries_ =
+                        com.google.protobuf.GeneratedMessageLite.mutableCopy(localStorageEntries_);
+                  }
+                  localStorageEntries_.add(
+                      input.readMessage(dev.cobalt.storage.StorageProto.LocalStorageEntry.parser(), extensionRegistry));
+                  break;
+                }
+              }
+            }
+          } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+            throw new RuntimeException(e.setUnfinishedMessage(this));
+          } catch (java.io.IOException e) {
+            throw new RuntimeException(
+                new com.google.protobuf.InvalidProtocolBufferException(
+                    e.getMessage()).setUnfinishedMessage(this));
+          } finally {
+          }
+        }
+        case GET_DEFAULT_INSTANCE: {
+          return DEFAULT_INSTANCE;
+        }
+        case GET_PARSER: {
+          if (PARSER == null) {    synchronized (dev.cobalt.storage.StorageProto.LocalStorage.class) {
+              if (PARSER == null) {
+                PARSER = new DefaultInstanceBasedParser(DEFAULT_INSTANCE);
+              }
+            }
+          }
+          return PARSER;
+        }
+      }
+      throw new UnsupportedOperationException();
+    }
+
+
+    // @@protoc_insertion_point(class_scope:cobalt.storage.LocalStorage)
+    private static final dev.cobalt.storage.StorageProto.LocalStorage DEFAULT_INSTANCE;
+    static {
+      DEFAULT_INSTANCE = new LocalStorage();
+      DEFAULT_INSTANCE.makeImmutable();
+    }
+
+    public static dev.cobalt.storage.StorageProto.LocalStorage getDefaultInstance() {
+      return DEFAULT_INSTANCE;
+    }
+
+    private static volatile com.google.protobuf.Parser<LocalStorage> PARSER;
+
+    public static com.google.protobuf.Parser<LocalStorage> parser() {
+      return DEFAULT_INSTANCE.getParserForType();
+    }
+  }
+
+  public interface StorageOrBuilder extends
+      // @@protoc_insertion_point(interface_extends:cobalt.storage.Storage)
+      com.google.protobuf.MessageLiteOrBuilder {
+
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    java.util.List<dev.cobalt.storage.StorageProto.Cookie> 
+        getCookiesList();
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    dev.cobalt.storage.StorageProto.Cookie getCookies(int index);
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    int getCookiesCount();
+
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    java.util.List<dev.cobalt.storage.StorageProto.LocalStorage> 
+        getLocalStoragesList();
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    dev.cobalt.storage.StorageProto.LocalStorage getLocalStorages(int index);
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    int getLocalStoragesCount();
+  }
+  /**
+   * <pre>
+   * The full storage.
+   * </pre>
+   *
+   * Protobuf type {@code cobalt.storage.Storage}
+   */
+  public  static final class Storage extends
+      com.google.protobuf.GeneratedMessageLite<
+          Storage, Storage.Builder> implements
+      // @@protoc_insertion_point(message_implements:cobalt.storage.Storage)
+      StorageOrBuilder {
+    private Storage() {
+      cookies_ = emptyProtobufList();
+      localStorages_ = emptyProtobufList();
+    }
+    public static final int COOKIES_FIELD_NUMBER = 1;
+    private com.google.protobuf.Internal.ProtobufList<dev.cobalt.storage.StorageProto.Cookie> cookies_;
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    public java.util.List<dev.cobalt.storage.StorageProto.Cookie> getCookiesList() {
+      return cookies_;
+    }
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    public java.util.List<? extends dev.cobalt.storage.StorageProto.CookieOrBuilder> 
+        getCookiesOrBuilderList() {
+      return cookies_;
+    }
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    public int getCookiesCount() {
+      return cookies_.size();
+    }
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    public dev.cobalt.storage.StorageProto.Cookie getCookies(int index) {
+      return cookies_.get(index);
+    }
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    public dev.cobalt.storage.StorageProto.CookieOrBuilder getCookiesOrBuilder(
+        int index) {
+      return cookies_.get(index);
+    }
+    private void ensureCookiesIsMutable() {
+      if (!cookies_.isModifiable()) {
+        cookies_ =
+            com.google.protobuf.GeneratedMessageLite.mutableCopy(cookies_);
+       }
+    }
+
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    private void setCookies(
+        int index, dev.cobalt.storage.StorageProto.Cookie value) {
+      if (value == null) {
+        throw new NullPointerException();
+      }
+      ensureCookiesIsMutable();
+      cookies_.set(index, value);
+    }
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    private void setCookies(
+        int index, dev.cobalt.storage.StorageProto.Cookie.Builder builderForValue) {
+      ensureCookiesIsMutable();
+      cookies_.set(index, builderForValue.build());
+    }
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    private void addCookies(dev.cobalt.storage.StorageProto.Cookie value) {
+      if (value == null) {
+        throw new NullPointerException();
+      }
+      ensureCookiesIsMutable();
+      cookies_.add(value);
+    }
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    private void addCookies(
+        int index, dev.cobalt.storage.StorageProto.Cookie value) {
+      if (value == null) {
+        throw new NullPointerException();
+      }
+      ensureCookiesIsMutable();
+      cookies_.add(index, value);
+    }
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    private void addCookies(
+        dev.cobalt.storage.StorageProto.Cookie.Builder builderForValue) {
+      ensureCookiesIsMutable();
+      cookies_.add(builderForValue.build());
+    }
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    private void addCookies(
+        int index, dev.cobalt.storage.StorageProto.Cookie.Builder builderForValue) {
+      ensureCookiesIsMutable();
+      cookies_.add(index, builderForValue.build());
+    }
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    private void addAllCookies(
+        java.lang.Iterable<? extends dev.cobalt.storage.StorageProto.Cookie> values) {
+      ensureCookiesIsMutable();
+      com.google.protobuf.AbstractMessageLite.addAll(
+          values, cookies_);
+    }
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    private void clearCookies() {
+      cookies_ = emptyProtobufList();
+    }
+    /**
+     * <pre>
+     * All the cookies.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+     */
+    private void removeCookies(int index) {
+      ensureCookiesIsMutable();
+      cookies_.remove(index);
+    }
+
+    public static final int LOCAL_STORAGES_FIELD_NUMBER = 2;
+    private com.google.protobuf.Internal.ProtobufList<dev.cobalt.storage.StorageProto.LocalStorage> localStorages_;
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    public java.util.List<dev.cobalt.storage.StorageProto.LocalStorage> getLocalStoragesList() {
+      return localStorages_;
+    }
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    public java.util.List<? extends dev.cobalt.storage.StorageProto.LocalStorageOrBuilder> 
+        getLocalStoragesOrBuilderList() {
+      return localStorages_;
+    }
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    public int getLocalStoragesCount() {
+      return localStorages_.size();
+    }
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    public dev.cobalt.storage.StorageProto.LocalStorage getLocalStorages(int index) {
+      return localStorages_.get(index);
+    }
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    public dev.cobalt.storage.StorageProto.LocalStorageOrBuilder getLocalStoragesOrBuilder(
+        int index) {
+      return localStorages_.get(index);
+    }
+    private void ensureLocalStoragesIsMutable() {
+      if (!localStorages_.isModifiable()) {
+        localStorages_ =
+            com.google.protobuf.GeneratedMessageLite.mutableCopy(localStorages_);
+       }
+    }
+
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    private void setLocalStorages(
+        int index, dev.cobalt.storage.StorageProto.LocalStorage value) {
+      if (value == null) {
+        throw new NullPointerException();
+      }
+      ensureLocalStoragesIsMutable();
+      localStorages_.set(index, value);
+    }
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    private void setLocalStorages(
+        int index, dev.cobalt.storage.StorageProto.LocalStorage.Builder builderForValue) {
+      ensureLocalStoragesIsMutable();
+      localStorages_.set(index, builderForValue.build());
+    }
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    private void addLocalStorages(dev.cobalt.storage.StorageProto.LocalStorage value) {
+      if (value == null) {
+        throw new NullPointerException();
+      }
+      ensureLocalStoragesIsMutable();
+      localStorages_.add(value);
+    }
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    private void addLocalStorages(
+        int index, dev.cobalt.storage.StorageProto.LocalStorage value) {
+      if (value == null) {
+        throw new NullPointerException();
+      }
+      ensureLocalStoragesIsMutable();
+      localStorages_.add(index, value);
+    }
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    private void addLocalStorages(
+        dev.cobalt.storage.StorageProto.LocalStorage.Builder builderForValue) {
+      ensureLocalStoragesIsMutable();
+      localStorages_.add(builderForValue.build());
+    }
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    private void addLocalStorages(
+        int index, dev.cobalt.storage.StorageProto.LocalStorage.Builder builderForValue) {
+      ensureLocalStoragesIsMutable();
+      localStorages_.add(index, builderForValue.build());
+    }
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    private void addAllLocalStorages(
+        java.lang.Iterable<? extends dev.cobalt.storage.StorageProto.LocalStorage> values) {
+      ensureLocalStoragesIsMutable();
+      com.google.protobuf.AbstractMessageLite.addAll(
+          values, localStorages_);
+    }
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    private void clearLocalStorages() {
+      localStorages_ = emptyProtobufList();
+    }
+    /**
+     * <pre>
+     * All local storages.
+     * </pre>
+     *
+     * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+     */
+    private void removeLocalStorages(int index) {
+      ensureLocalStoragesIsMutable();
+      localStorages_.remove(index);
+    }
+
+    public void writeTo(com.google.protobuf.CodedOutputStream output)
+                        throws java.io.IOException {
+      for (int i = 0; i < cookies_.size(); i++) {
+        output.writeMessage(1, cookies_.get(i));
+      }
+      for (int i = 0; i < localStorages_.size(); i++) {
+        output.writeMessage(2, localStorages_.get(i));
+      }
+    }
+
+    public int getSerializedSize() {
+      int size = memoizedSerializedSize;
+      if (size != -1) return size;
+
+      size = 0;
+      for (int i = 0; i < cookies_.size(); i++) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeMessageSize(1, cookies_.get(i));
+      }
+      for (int i = 0; i < localStorages_.size(); i++) {
+        size += com.google.protobuf.CodedOutputStream
+          .computeMessageSize(2, localStorages_.get(i));
+      }
+      memoizedSerializedSize = size;
+      return size;
+    }
+
+    public static dev.cobalt.storage.StorageProto.Storage parseFrom(
+        com.google.protobuf.ByteString data)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data);
+    }
+    public static dev.cobalt.storage.StorageProto.Storage parseFrom(
+        com.google.protobuf.ByteString data,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.Storage parseFrom(byte[] data)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data);
+    }
+    public static dev.cobalt.storage.StorageProto.Storage parseFrom(
+        byte[] data,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, data, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.Storage parseFrom(java.io.InputStream input)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input);
+    }
+    public static dev.cobalt.storage.StorageProto.Storage parseFrom(
+        java.io.InputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.Storage parseDelimitedFrom(java.io.InputStream input)
+        throws java.io.IOException {
+      return parseDelimitedFrom(DEFAULT_INSTANCE, input);
+    }
+    public static dev.cobalt.storage.StorageProto.Storage parseDelimitedFrom(
+        java.io.InputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      return parseDelimitedFrom(DEFAULT_INSTANCE, input, extensionRegistry);
+    }
+    public static dev.cobalt.storage.StorageProto.Storage parseFrom(
+        com.google.protobuf.CodedInputStream input)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input);
+    }
+    public static dev.cobalt.storage.StorageProto.Storage parseFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      return com.google.protobuf.GeneratedMessageLite.parseFrom(
+          DEFAULT_INSTANCE, input, extensionRegistry);
+    }
+
+    public static Builder newBuilder() {
+      return DEFAULT_INSTANCE.toBuilder();
+    }
+    public static Builder newBuilder(dev.cobalt.storage.StorageProto.Storage prototype) {
+      return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+    }
+
+    /**
+     * <pre>
+     * The full storage.
+     * </pre>
+     *
+     * Protobuf type {@code cobalt.storage.Storage}
+     */
+    public static final class Builder extends
+        com.google.protobuf.GeneratedMessageLite.Builder<
+          dev.cobalt.storage.StorageProto.Storage, Builder> implements
+        // @@protoc_insertion_point(builder_implements:cobalt.storage.Storage)
+        dev.cobalt.storage.StorageProto.StorageOrBuilder {
+      // Construct using dev.cobalt.storage.StorageProto.Storage.newBuilder()
+      private Builder() {
+        super(DEFAULT_INSTANCE);
+      }
+
+
+      /**
+       * <pre>
+       * All the cookies.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+       */
+      public java.util.List<dev.cobalt.storage.StorageProto.Cookie> getCookiesList() {
+        return java.util.Collections.unmodifiableList(
+            instance.getCookiesList());
+      }
+      /**
+       * <pre>
+       * All the cookies.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+       */
+      public int getCookiesCount() {
+        return instance.getCookiesCount();
+      }/**
+       * <pre>
+       * All the cookies.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+       */
+      public dev.cobalt.storage.StorageProto.Cookie getCookies(int index) {
+        return instance.getCookies(index);
+      }
+      /**
+       * <pre>
+       * All the cookies.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+       */
+      public Builder setCookies(
+          int index, dev.cobalt.storage.StorageProto.Cookie value) {
+        copyOnWrite();
+        instance.setCookies(index, value);
+        return this;
+      }
+      /**
+       * <pre>
+       * All the cookies.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+       */
+      public Builder setCookies(
+          int index, dev.cobalt.storage.StorageProto.Cookie.Builder builderForValue) {
+        copyOnWrite();
+        instance.setCookies(index, builderForValue);
+        return this;
+      }
+      /**
+       * <pre>
+       * All the cookies.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+       */
+      public Builder addCookies(dev.cobalt.storage.StorageProto.Cookie value) {
+        copyOnWrite();
+        instance.addCookies(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * All the cookies.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+       */
+      public Builder addCookies(
+          int index, dev.cobalt.storage.StorageProto.Cookie value) {
+        copyOnWrite();
+        instance.addCookies(index, value);
+        return this;
+      }
+      /**
+       * <pre>
+       * All the cookies.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+       */
+      public Builder addCookies(
+          dev.cobalt.storage.StorageProto.Cookie.Builder builderForValue) {
+        copyOnWrite();
+        instance.addCookies(builderForValue);
+        return this;
+      }
+      /**
+       * <pre>
+       * All the cookies.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+       */
+      public Builder addCookies(
+          int index, dev.cobalt.storage.StorageProto.Cookie.Builder builderForValue) {
+        copyOnWrite();
+        instance.addCookies(index, builderForValue);
+        return this;
+      }
+      /**
+       * <pre>
+       * All the cookies.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+       */
+      public Builder addAllCookies(
+          java.lang.Iterable<? extends dev.cobalt.storage.StorageProto.Cookie> values) {
+        copyOnWrite();
+        instance.addAllCookies(values);
+        return this;
+      }
+      /**
+       * <pre>
+       * All the cookies.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+       */
+      public Builder clearCookies() {
+        copyOnWrite();
+        instance.clearCookies();
+        return this;
+      }
+      /**
+       * <pre>
+       * All the cookies.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.Cookie cookies = 1;</code>
+       */
+      public Builder removeCookies(int index) {
+        copyOnWrite();
+        instance.removeCookies(index);
+        return this;
+      }
+
+      /**
+       * <pre>
+       * All local storages.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+       */
+      public java.util.List<dev.cobalt.storage.StorageProto.LocalStorage> getLocalStoragesList() {
+        return java.util.Collections.unmodifiableList(
+            instance.getLocalStoragesList());
+      }
+      /**
+       * <pre>
+       * All local storages.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+       */
+      public int getLocalStoragesCount() {
+        return instance.getLocalStoragesCount();
+      }/**
+       * <pre>
+       * All local storages.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+       */
+      public dev.cobalt.storage.StorageProto.LocalStorage getLocalStorages(int index) {
+        return instance.getLocalStorages(index);
+      }
+      /**
+       * <pre>
+       * All local storages.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+       */
+      public Builder setLocalStorages(
+          int index, dev.cobalt.storage.StorageProto.LocalStorage value) {
+        copyOnWrite();
+        instance.setLocalStorages(index, value);
+        return this;
+      }
+      /**
+       * <pre>
+       * All local storages.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+       */
+      public Builder setLocalStorages(
+          int index, dev.cobalt.storage.StorageProto.LocalStorage.Builder builderForValue) {
+        copyOnWrite();
+        instance.setLocalStorages(index, builderForValue);
+        return this;
+      }
+      /**
+       * <pre>
+       * All local storages.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+       */
+      public Builder addLocalStorages(dev.cobalt.storage.StorageProto.LocalStorage value) {
+        copyOnWrite();
+        instance.addLocalStorages(value);
+        return this;
+      }
+      /**
+       * <pre>
+       * All local storages.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+       */
+      public Builder addLocalStorages(
+          int index, dev.cobalt.storage.StorageProto.LocalStorage value) {
+        copyOnWrite();
+        instance.addLocalStorages(index, value);
+        return this;
+      }
+      /**
+       * <pre>
+       * All local storages.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+       */
+      public Builder addLocalStorages(
+          dev.cobalt.storage.StorageProto.LocalStorage.Builder builderForValue) {
+        copyOnWrite();
+        instance.addLocalStorages(builderForValue);
+        return this;
+      }
+      /**
+       * <pre>
+       * All local storages.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+       */
+      public Builder addLocalStorages(
+          int index, dev.cobalt.storage.StorageProto.LocalStorage.Builder builderForValue) {
+        copyOnWrite();
+        instance.addLocalStorages(index, builderForValue);
+        return this;
+      }
+      /**
+       * <pre>
+       * All local storages.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+       */
+      public Builder addAllLocalStorages(
+          java.lang.Iterable<? extends dev.cobalt.storage.StorageProto.LocalStorage> values) {
+        copyOnWrite();
+        instance.addAllLocalStorages(values);
+        return this;
+      }
+      /**
+       * <pre>
+       * All local storages.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+       */
+      public Builder clearLocalStorages() {
+        copyOnWrite();
+        instance.clearLocalStorages();
+        return this;
+      }
+      /**
+       * <pre>
+       * All local storages.
+       * </pre>
+       *
+       * <code>repeated .cobalt.storage.LocalStorage local_storages = 2;</code>
+       */
+      public Builder removeLocalStorages(int index) {
+        copyOnWrite();
+        instance.removeLocalStorages(index);
+        return this;
+      }
+
+      // @@protoc_insertion_point(builder_scope:cobalt.storage.Storage)
+    }
+    protected final Object dynamicMethod(
+        com.google.protobuf.GeneratedMessageLite.MethodToInvoke method,
+        Object arg0, Object arg1) {
+      switch (method) {
+        case NEW_MUTABLE_INSTANCE: {
+          return new dev.cobalt.storage.StorageProto.Storage();
+        }
+        case IS_INITIALIZED: {
+          return DEFAULT_INSTANCE;
+        }
+        case MAKE_IMMUTABLE: {
+          cookies_.makeImmutable();
+          localStorages_.makeImmutable();
+          return null;
+        }
+        case NEW_BUILDER: {
+          return new Builder();
+        }
+        case VISIT: {
+          Visitor visitor = (Visitor) arg0;
+          dev.cobalt.storage.StorageProto.Storage other = (dev.cobalt.storage.StorageProto.Storage) arg1;
+          cookies_= visitor.visitList(cookies_, other.cookies_);
+          localStorages_= visitor.visitList(localStorages_, other.localStorages_);
+          if (visitor == com.google.protobuf.GeneratedMessageLite.MergeFromVisitor
+              .INSTANCE) {
+          }
+          return this;
+        }
+        case MERGE_FROM_STREAM: {
+          com.google.protobuf.CodedInputStream input =
+              (com.google.protobuf.CodedInputStream) arg0;
+          com.google.protobuf.ExtensionRegistryLite extensionRegistry =
+              (com.google.protobuf.ExtensionRegistryLite) arg1;
+          try {
+            boolean done = false;
+            while (!done) {
+              int tag = input.readTag();
+              switch (tag) {
+                case 0:
+                  done = true;
+                  break;
+                default: {
+                  if (!input.skipField(tag)) {
+                    done = true;
+                  }
+                  break;
+                }
+                case 10: {
+                  if (!cookies_.isModifiable()) {
+                    cookies_ =
+                        com.google.protobuf.GeneratedMessageLite.mutableCopy(cookies_);
+                  }
+                  cookies_.add(
+                      input.readMessage(dev.cobalt.storage.StorageProto.Cookie.parser(), extensionRegistry));
+                  break;
+                }
+                case 18: {
+                  if (!localStorages_.isModifiable()) {
+                    localStorages_ =
+                        com.google.protobuf.GeneratedMessageLite.mutableCopy(localStorages_);
+                  }
+                  localStorages_.add(
+                      input.readMessage(dev.cobalt.storage.StorageProto.LocalStorage.parser(), extensionRegistry));
+                  break;
+                }
+              }
+            }
+          } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+            throw new RuntimeException(e.setUnfinishedMessage(this));
+          } catch (java.io.IOException e) {
+            throw new RuntimeException(
+                new com.google.protobuf.InvalidProtocolBufferException(
+                    e.getMessage()).setUnfinishedMessage(this));
+          } finally {
+          }
+        }
+        case GET_DEFAULT_INSTANCE: {
+          return DEFAULT_INSTANCE;
+        }
+        case GET_PARSER: {
+          if (PARSER == null) {    synchronized (dev.cobalt.storage.StorageProto.Storage.class) {
+              if (PARSER == null) {
+                PARSER = new DefaultInstanceBasedParser(DEFAULT_INSTANCE);
+              }
+            }
+          }
+          return PARSER;
+        }
+      }
+      throw new UnsupportedOperationException();
+    }
+
+
+    // @@protoc_insertion_point(class_scope:cobalt.storage.Storage)
+    private static final dev.cobalt.storage.StorageProto.Storage DEFAULT_INSTANCE;
+    static {
+      DEFAULT_INSTANCE = new Storage();
+      DEFAULT_INSTANCE.makeImmutable();
+    }
+
+    public static dev.cobalt.storage.StorageProto.Storage getDefaultInstance() {
+      return DEFAULT_INSTANCE;
+    }
+
+    private static volatile com.google.protobuf.Parser<Storage> PARSER;
+
+    public static com.google.protobuf.Parser<Storage> parser() {
+      return DEFAULT_INSTANCE.getParserForType();
+    }
+  }
+
+
+  static {
+  }
+
+  // @@protoc_insertion_point(outer_class_scope)
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/DisplayUtil.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/DisplayUtil.java
new file mode 100644
index 0000000..16f2b46
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/DisplayUtil.java
@@ -0,0 +1,82 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.util;
+
+import android.content.Context;
+import android.util.DisplayMetrics;
+import android.util.Size;
+import android.view.WindowManager;
+
+/** Utility functions for querying display attributes. */
+public class DisplayUtil {
+
+  private DisplayUtil() {}
+
+  /**
+   * Returns the size of the physical display size in pixels.
+   *
+   * <p>This differs from {@link #getSystemDisplaySize(Context)} because it only uses
+   * {@link DisplayMetrics}.
+   */
+  public static Size getDisplaySize(Context context) {
+    DisplayMetrics metrics = getDisplayMetrics(context);
+    return new Size(metrics.widthPixels, metrics.heightPixels);
+  }
+
+  /**
+   * Returns the size of the current physical display size in pixels.
+   *
+   * <p>This differs from {@link #getDisplaySize(Context)} because it allows the
+   * system property "sys.display-size" to override {@link DisplayMetrics}.
+   */
+  public static Size getSystemDisplaySize(Context context) {
+    Size widthAndHeightPx = getSystemDisplayWidthAndHeightPxInternal();
+    if (widthAndHeightPx == null) {
+      widthAndHeightPx = getDisplaySize(context);
+    }
+    return widthAndHeightPx;
+  }
+
+  /**
+   * Returns the size of the current physical display size in pixels.
+   * or {@code null} if unavailable.
+   */
+  private static Size getSystemDisplayWidthAndHeightPxInternal() {
+    final String displaySize = SystemPropertiesHelper.getString("sys.display-size");
+    if (displaySize != null) {
+      final String[] widthAndHeightPx = displaySize.split("x");
+      if (widthAndHeightPx.length == 2) {
+        try {
+          return new Size(
+              Integer.parseInt(widthAndHeightPx[0]), Integer.parseInt(widthAndHeightPx[1]));
+        } catch (NumberFormatException exception) {
+          // pass
+        }
+      }
+    }
+    return null;
+  }
+
+  private static DisplayMetrics cachedDisplayMetrics = null;
+
+  private static DisplayMetrics getDisplayMetrics(Context context) {
+    if (cachedDisplayMetrics == null) {
+      cachedDisplayMetrics = new DisplayMetrics();
+      ((WindowManager) context.getSystemService(Context.WINDOW_SERVICE))
+          .getDefaultDisplay().getRealMetrics(cachedDisplayMetrics);
+    }
+    return cachedDisplayMetrics;
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/Holder.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/Holder.java
new file mode 100644
index 0000000..7fa3407
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/Holder.java
@@ -0,0 +1,35 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.util;
+
+import android.support.annotation.Nullable;
+
+/** Holds a mutable reference to an object, or null. */
+public class Holder<T> {
+  private T instance;
+
+  public Holder() {
+    this.instance = null;
+  }
+
+  public void set(@Nullable T instance) {
+    this.instance = instance;
+  }
+
+  @Nullable
+  public T get() {
+    return instance;
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/IsEmulator.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/IsEmulator.java
new file mode 100644
index 0000000..0ade400
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/IsEmulator.java
@@ -0,0 +1,29 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.util;
+
+import android.os.Build;
+
+/** A simple utility class to detect whether we're running in an emulator or not. */
+public class IsEmulator {
+  private IsEmulator() {}
+
+  public static boolean isEmulator() {
+    String qemu = System.getProperty("ro.kernel.qemu", "?");
+    return qemu.equals("1")
+        || Build.HARDWARE.contains("goldfish")
+        || Build.HARDWARE.contains("ranchu");
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/Log.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/Log.java
new file mode 100644
index 0000000..59af6ac
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/Log.java
@@ -0,0 +1,71 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.util;
+
+/**
+ * API for sending Starboard log output. This uses a JNI helper rather than directly calling Android
+ * logging so that it remains in the app even when Android logging is stripped by Proguard.
+ */
+public final class Log {
+  public static final String TAG = "starboard";
+
+  private Log() {}
+
+  private static native int nativeWrite(char priority, String tag, String msg, Throwable tr);
+
+  public static int v(String tag, String msg) {
+    return nativeWrite('v', tag, msg, null);
+  }
+
+  public static int v(String tag, String msg, Throwable tr) {
+    return nativeWrite('v', tag, msg, tr);
+  }
+
+  public static int d(String tag, String msg) {
+    return nativeWrite('d', tag, msg, null);
+  }
+
+  public static int d(String tag, String msg, Throwable tr) {
+    return nativeWrite('d', tag, msg, tr);
+  }
+
+  public static int i(String tag, String msg) {
+    return nativeWrite('i', tag, msg, null);
+  }
+
+  public static int i(String tag, String msg, Throwable tr) {
+    return nativeWrite('i', tag, msg, tr);
+  }
+
+  public static int w(String tag, String msg) {
+    return nativeWrite('w', tag, msg, null);
+  }
+
+  public static int w(String tag, String msg, Throwable tr) {
+    return nativeWrite('w', tag, msg, tr);
+  }
+
+  public static int w(String tag, Throwable tr) {
+    return nativeWrite('w', tag, "", tr);
+  }
+
+  public static int e(String tag, String msg) {
+    return nativeWrite('e', tag, msg, null);
+  }
+
+  public static int e(String tag, String msg, Throwable tr) {
+    return nativeWrite('e', tag, msg, tr);
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/SystemPropertiesHelper.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/SystemPropertiesHelper.java
new file mode 100644
index 0000000..90448c8
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/SystemPropertiesHelper.java
@@ -0,0 +1,49 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.util;
+
+import static dev.cobalt.util.Log.TAG;
+
+import java.lang.reflect.Method;
+
+/** Utility class for accessing system properties via reflection. */
+public class SystemPropertiesHelper {
+  private static Method getStringMethod;
+  static {
+    try {
+      getStringMethod = ClassLoader.getSystemClassLoader()
+          .loadClass("android.os.SystemProperties")
+          .getMethod("get", String.class);
+      if (getStringMethod == null) {
+        Log.e(TAG, "Couldn't load system properties getString");
+      }
+    } catch (Exception exception) {
+      Log.e(TAG, "Exception looking up system properties methods: ", exception);
+    }
+  }
+
+  private SystemPropertiesHelper() {}
+
+  public static String getString(String property) {
+    if (getStringMethod != null) {
+      try {
+        return (String) getStringMethod.invoke(null, new Object[] { property });
+      } catch (Exception exception) {
+        Log.e(TAG, "Exception getting system property: ", exception);
+      }
+    }
+    return null;
+  }
+}
diff --git a/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/UsedByNative.java b/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/UsedByNative.java
new file mode 100644
index 0000000..d78647d
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/java/dev/cobalt/util/UsedByNative.java
@@ -0,0 +1,25 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dev.cobalt.util;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation used for marking methods and fields that are called from native code. Useful for
+ * keeping components that would otherwise be removed by Proguard.
+ */
+@Target({ElementType.METHOD, ElementType.FIELD, ElementType.TYPE, ElementType.CONSTRUCTOR})
+public @interface UsedByNative {}
diff --git a/src/starboard/android/apk/app/src/main/res/layout/coat_error_dialog.xml b/src/starboard/android/apk/app/src/main/res/layout/coat_error_dialog.xml
new file mode 100644
index 0000000..c3f6390
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/res/layout/coat_error_dialog.xml
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2017 The Cobalt Authors. All Rights Reserved.
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/error_frame"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:focusable="true"
+    android:focusableInTouchMode="true"
+    android:descendantFocusability="afterDescendants"
+    android:nextFocusLeft="@id/error_frame"
+    android:nextFocusRight="@id/error_frame"
+    android:nextFocusUp="@id/error_frame"
+    android:nextFocusDown="@id/error_frame">
+
+  <LinearLayout
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:layout_gravity="center"
+      android:orientation="vertical">
+
+    <ImageView
+        android:id="@+id/image"
+        android:layout_width="wrap_content"
+        android:layout_height="@dimen/lb_error_image_max_height"
+        android:layout_gravity="center" />
+    <TextView
+        android:id="@+id/message"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:maxWidth="@dimen/lb_error_message_max_width"
+        style="?attr/errorMessageStyle"/>
+    <Button
+        android:id="@+id/button_1"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:visibility="gone"
+        style="?android:attr/buttonStyle"/>
+    <Button
+        android:id="@+id/button_2"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:visibility="gone"
+        style="?android:attr/buttonStyle"/>
+    <Button
+        android:id="@+id/button_3"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:visibility="gone"
+        style="?android:attr/buttonStyle"/>
+  </LinearLayout>
+
+</FrameLayout>
diff --git a/src/starboard/android/apk/app/src/main/res/values/colors.xml b/src/starboard/android/apk/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..30fde0c
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/res/values/colors.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2017 The Cobalt Authors. All Rights Reserved.
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+
+<resources>
+  <color name="cobalt_blue">#ff16396b</color>
+  <color name="primary">@color/cobalt_blue</color>
+</resources>
diff --git a/src/starboard/android/apk/app/src/main/res/values/ids.xml b/src/starboard/android/apk/app/src/main/res/values/ids.xml
new file mode 100644
index 0000000..8097776
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/res/values/ids.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2017 The Cobalt Authors. All Rights Reserved.
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+
+<resources>
+  <item name="rc_choose_account" type="id"/>
+  <item name="rc_get_accounts_permission" type="id"/>
+  <item name="rc_record_audio" type="id"/>
+</resources>
diff --git a/src/starboard/android/apk/app/src/main/res/values/strings.xml b/src/starboard/android/apk/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..8b7369f
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/res/values/strings.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2016 The Cobalt Authors. All Rights Reserved.
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+
+<resources>
+  <!-- Error message when there's a problem connecting to the network. -->
+  <string name="starboard_platform_connection_error">Can\'t connect right now</string>
+  <!-- Button label on network connection error to retry the request. -->
+  <string name="starboard_platform_retry">Try again</string>
+  <!-- Button label on network connection error to open system network settings. -->
+  <string name="starboard_platform_network_settings">Open network settings</string>
+  <!-- Toast message when we can't get the device account to sign in. -->
+  <string name="starboard_account_picker_error">Account not available.</string>
+  <!-- Toast message when we the account can't be authorized. -->
+  <string name="starboard_account_auth_error">Account authorization failed.</string>
+  <!-- Toast message indicating that the selected account is no longer available to sign-in. -->
+  <string name="starboard_missing_account">Account not available:\n%1$s</string>
+  <!-- Toast message shown when the user has denied permission to get device accounts. -->
+  <string name="starboard_accounts_permission">Sign in requires Contacts permission to be granted in system settings.</string>
+</resources>
diff --git a/src/starboard/android/apk/app/src/main/res/values/styles.xml b/src/starboard/android/apk/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..ef05d47
--- /dev/null
+++ b/src/starboard/android/apk/app/src/main/res/values/styles.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2016 The Cobalt Authors. All Rights Reserved.
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+
+<resources>
+  <style name="CobaltTheme" parent="@style/android:Theme.Material.NoActionBar">
+    <!-- Color of the transition animation when launching the app. -->
+    <item name="android:colorPrimary">@color/primary</item>
+
+    <!-- Color shown after the transition before the window is attached.
+         Avoids a black flash between the transition and splash screen. -->
+    <item name="android:windowBackground">?android:colorPrimary</item>
+
+    <item name="android:dialogTheme">@style/FullscreenDialogTheme</item>
+  </style>
+
+  <style name="FullscreenDialogTheme" parent="Theme.Leanback">
+    <item name="android:layout_width">fill_parent</item>
+    <item name="android:layout_height">fill_parent</item>
+    <item name="android:windowIsTranslucent">true</item>
+    <item name="android:windowBackground">@color/lb_error_background_color_translucent</item>
+    <item name="android:windowNoTitle">true</item>
+    <item name="android:windowIsFloating">false</item>
+    <item name="android:windowOverscan">true</item>
+    <item name="android:backgroundDimEnabled">false</item>
+    <item name="errorMessageStyle">@style/ErrorMessageStyle</item>
+    <!-- Make the buttons darker so that you can see the focus highlight on the dark background. -->
+    <item name="android:colorButtonNormal">#282828</item>
+  </style>
+
+  <style name="ErrorMessageStyle" parent="Widget.Leanback.ErrorMessageStyle">
+    <item name="android:layout_margin">16dp</item>
+  </style>
+</resources>
diff --git a/src/starboard/android/apk/build.gradle b/src/starboard/android/apk/build.gradle
new file mode 100644
index 0000000..b50ae22
--- /dev/null
+++ b/src/starboard/android/apk/build.gradle
@@ -0,0 +1,55 @@
+// Copyright 2016 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// 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.3.0'
+
+        // NOTE: Do not place your application dependencies here; they belong
+        // in the individual module build.gradle files
+    }
+}
+
+allprojects {
+    repositories {
+        google()
+        jcenter()
+    }
+    gradle.projectsEvaluated {
+        tasks.withType(JavaCompile) {
+            options.compilerArgs += [
+                    "-Xlint:unchecked",
+                    "-Xlint:deprecation",
+                    ]
+        }
+    }
+}
+
+task clean(type: Delete) {
+    delete rootProject.buildDir
+}
+
+// Move the 'buildDir' for all projects into sub-directories of a shared top-level build directory,
+// which is either the root's original 'buildDir' or a custom location when building for platform
+// deploy.  Note that the platform deploy action sets a custom 'cobaltGradleDir' property rather
+// than setting 'buildDir' directly on the command line since Gradle trys to get smart about
+// 'buildDir' which can end up putting it at the wrong depth in the file system.
+def rootBuildDir = hasProperty('cobaltGradleDir') ? new File(cobaltGradleDir, 'build') : buildDir
+allprojects { buildDir = new File(rootBuildDir, project.name).canonicalFile }
diff --git a/src/starboard/android/apk/cobalt-gradle.sh b/src/starboard/android/apk/cobalt-gradle.sh
new file mode 100755
index 0000000..d135b4b
--- /dev/null
+++ b/src/starboard/android/apk/cobalt-gradle.sh
@@ -0,0 +1,64 @@
+#!/bin/bash
+# Copyright 2016 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Helper to set ANDROID_HOME and ANDROID_NDK_HOME from command-line args
+# before running gradlew, as specified by leading --sdk and --ndk args.
+#
+# Also resets hung gradle builds when specified by a leading --reset arg.
+
+GRADLE_ARGS=()
+while [ "$1" ]; do
+  case "$1" in
+    --sdk) shift; ANDROID_HOME="$1" ;;
+    --ndk) shift; ANDROID_NDK_HOME="$1" ;;
+    --cache) shift; mkdir -p "$1";
+             GRADLE_ARGS+=("--project-cache-dir" $(cd "$1"; pwd)) ;;
+    --reset) RESET_GRADLE=1 ;;
+    *) break ;;
+  esac
+  shift
+done
+GRADLE_ARGS+=("$@")
+
+# Cleanup Gradle from previous builds. Used as part of the GYP step.
+if [[ "${RESET_GRADLE}" ]]; then
+  echo "Cleaning Gradle deamons and locks."
+  # If there are any lock files, kill any hung processes still waiting on them.
+  if compgen -G '/var/lock/cobalt-gradle.lock.*'; then
+    lsof -t /var/lock/cobalt-gradle.lock.* | xargs -rt kill
+  fi
+  # Stop the Gradle daemon (if still running).
+  $(dirname "$0")/gradlew --stop
+  # Remove Gradle caches (including its lock files).
+  rm -rf ${HOME}/.gradle/caches
+  # Show the gradle version, which will cause it to download if needed.
+  $(dirname "$0")/gradlew -v
+  # After resetting, exit without running any gradle tasks.
+  exit
+fi
+
+export ANDROID_HOME
+export ANDROID_NDK_HOME
+echo "ANDROID_HOME=${ANDROID_HOME}"
+echo "ANDROID_NDK_HOME=${ANDROID_NDK_HOME}"
+echo "TASK: ${GRADLE_ARGS[-1]}"
+
+# Allow parallel gradle builds, as defined by a COBALT_GRADLE_BUILD_COUNT envvar
+# or default to 1 if that's not set (so buildbot only runs 1 gradle at a time).
+BUCKETS=${COBALT_GRADLE_BUILD_COUNT:-1}
+MD5=$(echo "${GRADLE_ARGS[@]}" | md5sum)
+LOCKNUM=$(( ${BUCKETS} * 0x${MD5:0:6} / 0x1000000 ))
+
+flock /var/lock/cobalt-gradle.lock.${LOCKNUM} $(dirname "$0")/gradlew "${GRADLE_ARGS[@]}"
diff --git a/src/starboard/android/apk/gradle.properties b/src/starboard/android/apk/gradle.properties
new file mode 100644
index 0000000..2d5bfe3
--- /dev/null
+++ b/src/starboard/android/apk/gradle.properties
@@ -0,0 +1,31 @@
+# Copyright 2016 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# 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=-Xmx4g
+
+# 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/src/starboard/android/apk/gradle/wrapper/gradle-wrapper.jar b/src/starboard/android/apk/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..13372ae
--- /dev/null
+++ b/src/starboard/android/apk/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/src/starboard/android/apk/gradle/wrapper/gradle-wrapper.properties b/src/starboard/android/apk/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..b17c1fc
--- /dev/null
+++ b/src/starboard/android/apk/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Tue Jan 29 11:03:00 PST 2019
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip
diff --git a/src/starboard/android/apk/gradlew b/src/starboard/android/apk/gradlew
new file mode 100755
index 0000000..9d82f78
--- /dev/null
+++ b/src/starboard/android/apk/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/src/starboard/android/apk/gradlew.bat b/src/starboard/android/apk/gradlew.bat
new file mode 100644
index 0000000..aec9973
--- /dev/null
+++ b/src/starboard/android/apk/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/src/starboard/android/apk/settings.gradle b/src/starboard/android/apk/settings.gradle
new file mode 100644
index 0000000..1abb6c2
--- /dev/null
+++ b/src/starboard/android/apk/settings.gradle
@@ -0,0 +1,15 @@
+// Copyright 2016 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+include ':app'
diff --git a/src/starboard/android/arm/atomic_public.h b/src/starboard/android/arm/atomic_public.h
new file mode 100644
index 0000000..57fad75
--- /dev/null
+++ b/src/starboard/android/arm/atomic_public.h
@@ -0,0 +1,15 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "starboard/android/shared/atomic_public.h"
diff --git a/src/starboard/android/arm/configuration_public.h b/src/starboard/android/arm/configuration_public.h
new file mode 100644
index 0000000..aa26c16
--- /dev/null
+++ b/src/starboard/android/arm/configuration_public.h
@@ -0,0 +1,22 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The Starboard configuration for Android ARM. Other devices will have
+// specific Starboard implementations, even if they ultimately are running some
+// version of Android.
+
+// Other source files should never include this header directly, but should
+// include the generic "starboard/configuration.h" instead.
+
+#include "starboard/android/shared/configuration_public.h"
diff --git a/src/starboard/android/arm/gyp_configuration.gypi b/src/starboard/android/arm/gyp_configuration.gypi
new file mode 100644
index 0000000..d3302b1
--- /dev/null
+++ b/src/starboard/android/arm/gyp_configuration.gypi
@@ -0,0 +1,53 @@
+# Copyright 2017 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+{
+  'variables': {
+    'target_arch': 'arm',
+    'arm_version': 7,
+    'armv7': 1,
+    'arm_thumb': 0,
+    'arm_neon': 0,
+    'arm_fpu': 'vfpv3-d16',
+    'compiler_flags': [
+      '-march=armv7-a',
+    ],
+    'linker_flags': [
+      # Mimic build/cmake/android.toolchain.cmake in the Android NDK.
+      '-Wl,--exclude-libs,libunwind.a',
+    ]
+  },
+
+  'target_defaults': {
+    'default_configuration': 'android-arm_debug',
+    'configurations': {
+      'android-arm_debug': {
+        'inherit_from': ['debug_base'],
+      },
+      'android-arm_devel': {
+        'inherit_from': ['devel_base'],
+      },
+      'android-arm_qa': {
+        'inherit_from': ['qa_base'],
+      },
+      'android-arm_gold': {
+        'inherit_from': ['gold_base'],
+      },
+    }, # end of configurations
+  },
+
+  'includes': [
+    '../shared/gyp_configuration.gypi',
+  ],
+}
diff --git a/src/starboard/android/arm/gyp_configuration.py b/src/starboard/android/arm/gyp_configuration.py
new file mode 100644
index 0000000..b804d2f
--- /dev/null
+++ b/src/starboard/android/arm/gyp_configuration.py
@@ -0,0 +1,20 @@
+# Copyright 2017 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Starboard Android ARM platform build configuration."""
+
+from starboard.android.shared import gyp_configuration as shared_configuration
+
+
+def CreatePlatformConfig():
+  return shared_configuration.AndroidConfiguration('android-arm', 'armeabi-v7a')
diff --git a/src/starboard/android/arm/starboard_platform.gyp b/src/starboard/android/arm/starboard_platform.gyp
new file mode 100644
index 0000000..5110292
--- /dev/null
+++ b/src/starboard/android/arm/starboard_platform.gyp
@@ -0,0 +1,16 @@
+# Copyright 2017 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+{
+  'includes': [ '../shared/starboard_platform.gypi' ],
+}
diff --git a/src/starboard/android/arm/starboard_platform_tests.gyp b/src/starboard/android/arm/starboard_platform_tests.gyp
new file mode 100644
index 0000000..58cd108
--- /dev/null
+++ b/src/starboard/android/arm/starboard_platform_tests.gyp
@@ -0,0 +1,16 @@
+# Copyright 2017 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+{
+  'includes': [ '../shared/starboard_platform_tests.gypi' ],
+}
diff --git a/src/starboard/android/arm/thread_types_public.h b/src/starboard/android/arm/thread_types_public.h
new file mode 100644
index 0000000..0b84b59
--- /dev/null
+++ b/src/starboard/android/arm/thread_types_public.h
@@ -0,0 +1,15 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "starboard/android/shared/thread_types_public.h"
diff --git a/src/starboard/android/arm64/atomic_public.h b/src/starboard/android/arm64/atomic_public.h
new file mode 100644
index 0000000..57fad75
--- /dev/null
+++ b/src/starboard/android/arm64/atomic_public.h
@@ -0,0 +1,15 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "starboard/android/shared/atomic_public.h"
diff --git a/src/starboard/android/arm64/configuration_public.h b/src/starboard/android/arm64/configuration_public.h
new file mode 100644
index 0000000..52f31dc
--- /dev/null
+++ b/src/starboard/android/arm64/configuration_public.h
@@ -0,0 +1,22 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The Starboard configuration for Android ARM-64. Other devices will have
+// specific Starboard implementations, even if they ultimately are running some
+// version of Android.
+
+// Other source files should never include this header directly, but should
+// include the generic "starboard/configuration.h" instead.
+
+#include "starboard/android/shared/configuration_public.h"
diff --git a/src/starboard/android/arm64/gyp_configuration.gypi b/src/starboard/android/arm64/gyp_configuration.gypi
new file mode 100644
index 0000000..cc3a2b5
--- /dev/null
+++ b/src/starboard/android/arm64/gyp_configuration.gypi
@@ -0,0 +1,50 @@
+# Copyright 2017 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+{
+  'variables': {
+    'target_arch': 'arm64',
+    'arm_version': 8,
+    'armv7': 0,
+    'arm_thumb': 0,
+    'arm_neon': 0,
+    'arm_fpu': 'vfpv3-d16',
+    'linker_flags': [
+      '-Wl,--no-keep-memory',
+    ],
+
+  },
+
+  'target_defaults': {
+    'default_configuration': 'android-arm64_debug',
+    'configurations': {
+      'android-arm64_debug': {
+        'inherit_from': ['debug_base'],
+      },
+      'android-arm64_devel': {
+        'inherit_from': ['devel_base'],
+      },
+      'android-arm64_qa': {
+        'inherit_from': ['qa_base'],
+      },
+      'android-arm64_gold': {
+        'inherit_from': ['gold_base'],
+      },
+    }, # end of configurations
+  },
+
+  'includes': [
+    '../shared/gyp_configuration.gypi',
+  ],
+}
diff --git a/src/starboard/android/arm64/gyp_configuration.py b/src/starboard/android/arm64/gyp_configuration.py
new file mode 100644
index 0000000..215dc50
--- /dev/null
+++ b/src/starboard/android/arm64/gyp_configuration.py
@@ -0,0 +1,20 @@
+# Copyright 2017 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Starboard Android ARM-64 platform build configuration."""
+
+from starboard.android.shared import gyp_configuration as shared_configuration
+
+
+def CreatePlatformConfig():
+  return shared_configuration.AndroidConfiguration('android-arm64', 'arm64-v8a')
diff --git a/src/starboard/android/arm64/starboard_platform.gyp b/src/starboard/android/arm64/starboard_platform.gyp
new file mode 100644
index 0000000..5110292
--- /dev/null
+++ b/src/starboard/android/arm64/starboard_platform.gyp
@@ -0,0 +1,16 @@
+# Copyright 2017 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+{
+  'includes': [ '../shared/starboard_platform.gypi' ],
+}
diff --git a/src/starboard/android/arm64/starboard_platform_tests.gyp b/src/starboard/android/arm64/starboard_platform_tests.gyp
new file mode 100644
index 0000000..58cd108
--- /dev/null
+++ b/src/starboard/android/arm64/starboard_platform_tests.gyp
@@ -0,0 +1,16 @@
+# Copyright 2017 The Cobalt Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+{
+  'includes': [ '../shared/starboard_platform_tests.gypi' ],
+}
diff --git a/src/starboard/android/arm64/thread_types_public.h b/src/starboard/android/arm64/thread_types_public.h
new file mode 100644
index 0000000..0b84b59
--- /dev/null
+++ b/src/starboard/android/arm64/thread_types_public.h
@@ -0,0 +1,15 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "starboard/android/shared/thread_types_public.h"
diff --git a/src/starboard/android/shared/__init__.py b/src/starboard/android/shared/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/starboard/android/shared/__init__.py
diff --git a/src/starboard/android/shared/accessibility_get_caption_settings.cc b/src/starboard/android/shared/accessibility_get_caption_settings.cc
new file mode 100644
index 0000000..58e3067
--- /dev/null
+++ b/src/starboard/android/shared/accessibility_get_caption_settings.cc
@@ -0,0 +1,322 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <cmath>
+#include <cstdlib>
+#include <limits>
+#include <string>
+
+#include "base/compiler_specific.h"
+#include "base/logging.h"
+
+#include "starboard/accessibility.h"
+#include "starboard/android/shared/jni_env_ext.h"
+#include "starboard/android/shared/jni_utils.h"
+#include "starboard/configuration.h"
+#include "starboard/log.h"
+#include "starboard/memory.h"
+
+using starboard::android::shared::JniEnvExt;
+using starboard::android::shared::ScopedLocalJavaRef;
+
+namespace {
+
+const int kRgbWhite = 0xFFFFFF;
+const int kRgbBlack = 0x000000;
+const int kRgbRed = 0xFF0000;
+const int kRgbYellow = 0xFFFF00;
+const int kRgbGreen = 0x00FF00;
+const int kRgbCyan = 0x00FFFF;
+const int kRgbBlue = 0x0000FF;
+const int kRgbMagenta = 0xFF00FF;
+
+const int kRgbColors[] = {
+  kRgbWhite,
+  kRgbBlack,
+  kRgbRed,
+  kRgbYellow,
+  kRgbGreen,
+  kRgbCyan,
+  kRgbBlue,
+  kRgbMagenta,
+};
+
+SbAccessibilityCaptionColor GetClosestCaptionColor(int color) {
+  int ref_color = kRgbWhite;
+  int min_distance = std::numeric_limits<int>::max();
+
+  int r = 0xFF & (color >> 16);
+  int g = 0xFF & (color >> 8);
+  int b = 0xFF & (color);
+
+  // Find the reference color with the least distance (squared).
+  for (int i = 0; i < SB_ARRAY_SIZE(kRgbColors); i++) {
+    int r_ref = 0xFF & (kRgbColors[i] >> 16);
+    int g_ref = 0xFF & (kRgbColors[i] >> 8);
+    int b_ref = 0xFF & (kRgbColors[i]);
+    int distance_squared = pow(r - r_ref, 2) +
+                           pow(g - g_ref, 2) +
+                           pow(b - b_ref, 2);
+    if (distance_squared < min_distance) {
+      ref_color = kRgbColors[i];
+      min_distance = distance_squared;
+    }
+  }
+
+  switch (ref_color) {
+    case kRgbWhite:
+      return kSbAccessibilityCaptionColorWhite;
+    case kRgbBlack:
+      return kSbAccessibilityCaptionColorBlack;
+    case kRgbRed:
+      return kSbAccessibilityCaptionColorRed;
+    case kRgbYellow:
+      return kSbAccessibilityCaptionColorYellow;
+    case kRgbGreen:
+      return kSbAccessibilityCaptionColorGreen;
+    case kRgbCyan:
+      return kSbAccessibilityCaptionColorCyan;
+    case kRgbBlue:
+      return kSbAccessibilityCaptionColorBlue;
+    case kRgbMagenta:
+      return kSbAccessibilityCaptionColorMagenta;
+    default:
+      NOTREACHED() << "Invalid RGB color conversion";
+      return kSbAccessibilityCaptionColorWhite;
+  }
+}
+
+SbAccessibilityCaptionCharacterEdgeStyle
+AndroidEdgeTypeToSbEdgeStyle(int edge_type) {
+  switch (edge_type) {
+    case 0:
+      return kSbAccessibilityCaptionCharacterEdgeStyleNone;
+    case 1:
+      return kSbAccessibilityCaptionCharacterEdgeStyleUniform;
+    case 2:
+      return kSbAccessibilityCaptionCharacterEdgeStyleDropShadow;
+    case 3:
+      return kSbAccessibilityCaptionCharacterEdgeStyleRaised;
+    case 4:
+      return kSbAccessibilityCaptionCharacterEdgeStyleDepressed;
+    default:
+      NOTREACHED() << "Invalid edge type conversion";
+      return kSbAccessibilityCaptionCharacterEdgeStyleNone;
+  }
+}
+
+SbAccessibilityCaptionFontFamily AndroidFontFamilyToSbFontFamily(int family) {
+  switch (family) {
+    case 0:
+      return kSbAccessibilityCaptionFontFamilyCasual;
+    case 1:
+      return kSbAccessibilityCaptionFontFamilyCursive;
+    case 2:
+      return kSbAccessibilityCaptionFontFamilyMonospaceSansSerif;
+    case 3:
+      return kSbAccessibilityCaptionFontFamilyMonospaceSerif;
+    case 4:
+      return kSbAccessibilityCaptionFontFamilyProportionalSansSerif;
+    case 5:
+      return kSbAccessibilityCaptionFontFamilyProportionalSerif;
+    case 6:
+      return kSbAccessibilityCaptionFontFamilySmallCapitals;
+    default:
+      NOTREACHED() << "Invalid font family conversion";
+      return kSbAccessibilityCaptionFontFamilyCasual;
+  }
+}
+
+int FindClosestReferenceValue(int value, const int reference[],
+                              size_t reference_size) {
+  int result = reference[0];
+  int min_difference = std::numeric_limits<int>::max();
+
+  for (int i = 0; i < reference_size; i++) {
+    int difference = abs(reference[i] - value);
+    if (difference < min_difference) {
+      result = reference[i];
+      min_difference = difference;
+    }
+  }
+  return result;
+}
+
+const int kFontSizes[] = {
+  25,
+  50,
+  75,
+  100,
+  125,
+  150,
+  175,
+  200,
+  225,
+  250,
+  275,
+  300
+};
+
+SbAccessibilityCaptionFontSizePercentage GetClosestFontSizePercentage(
+    int font_size_percent) {
+  int reference_size = FindClosestReferenceValue(
+      font_size_percent, kFontSizes, SB_ARRAY_SIZE(kFontSizes));
+  switch (reference_size) {
+    case 25:
+      return kSbAccessibilityCaptionFontSizePercentage25;
+    case 50:
+      return kSbAccessibilityCaptionFontSizePercentage50;
+    case 75:
+      return kSbAccessibilityCaptionFontSizePercentage75;
+    case 100:
+      return kSbAccessibilityCaptionFontSizePercentage100;
+    case 125:
+      return kSbAccessibilityCaptionFontSizePercentage125;
+    case 150:
+      return kSbAccessibilityCaptionFontSizePercentage150;
+    case 175:
+      return kSbAccessibilityCaptionFontSizePercentage175;
+    case 200:
+      return kSbAccessibilityCaptionFontSizePercentage200;
+    case 225:
+      return kSbAccessibilityCaptionFontSizePercentage225;
+    case 250:
+      return kSbAccessibilityCaptionFontSizePercentage250;
+    case 275:
+      return kSbAccessibilityCaptionFontSizePercentage275;
+    case 300:
+      return kSbAccessibilityCaptionFontSizePercentage300;
+    default:
+      NOTREACHED() << "Invalid font size";
+      return kSbAccessibilityCaptionFontSizePercentage100;
+  }
+}
+
+const int kOpacities[] = {
+  0,
+  25,
+  50,
+  75,
+  100,
+};
+
+SbAccessibilityCaptionOpacityPercentage GetClosestOpacity(int opacity_percent) {
+  int reference_opacity_percent = FindClosestReferenceValue(
+      opacity_percent, kOpacities, SB_ARRAY_SIZE(kOpacities));
+  switch (reference_opacity_percent) {
+    case 0:
+      return kSbAccessibilityCaptionOpacityPercentage0;
+    case 25:
+      return kSbAccessibilityCaptionOpacityPercentage25;
+    case 50:
+      return kSbAccessibilityCaptionOpacityPercentage50;
+    case 75:
+      return kSbAccessibilityCaptionOpacityPercentage75;
+    case 100:
+      return kSbAccessibilityCaptionOpacityPercentage100;
+    default:
+      NOTREACHED() << "Invalid opacity percentage";
+      return kSbAccessibilityCaptionOpacityPercentage100;
+  }
+}
+
+SbAccessibilityCaptionState BooleanToCaptionState(bool is_set) {
+  if (is_set) {
+    return kSbAccessibilityCaptionStateSet;
+  } else {
+    return kSbAccessibilityCaptionStateUnset;
+  }
+}
+
+void SetColorProperties(jobject j_caption_settings,
+                        const char* color_field,
+                        const char* has_color_field,
+                        SbAccessibilityCaptionColor* color,
+                        SbAccessibilityCaptionState* color_state,
+                        SbAccessibilityCaptionOpacityPercentage* opacity,
+                        SbAccessibilityCaptionState* opacity_state) {
+  JniEnvExt* env = JniEnvExt::Get();
+  jint j_color = env->GetIntFieldOrAbort(j_caption_settings, color_field, "I");
+  *color = GetClosestCaptionColor(j_color);
+  *opacity = GetClosestOpacity((0xFF & (j_color >> 24)) * 100 / 255);
+  *color_state = BooleanToCaptionState(
+      env->GetBooleanFieldOrAbort(j_caption_settings, has_color_field, "Z"));
+  // Color and opacity are combined into a single ARGB value.
+  // Therefore, if the color is set, so is the opacity.
+  *opacity_state = *color_state;
+}
+
+}  // namespace
+
+bool SbAccessibilityGetCaptionSettings(
+  SbAccessibilityCaptionSettings* caption_settings) {
+  if (!caption_settings ||
+      !SbMemoryIsZero(caption_settings,
+                      sizeof(SbAccessibilityCaptionSettings))) {
+    return false;
+  }
+
+  JniEnvExt* env = JniEnvExt::Get();
+
+  ScopedLocalJavaRef<jobject> j_caption_settings(
+      env->CallStarboardObjectMethodOrAbort(
+          "getCaptionSettings", "()Ldev/cobalt/media/CaptionSettings;"));
+
+  jfloat font_scale =
+      env->GetFloatFieldOrAbort(j_caption_settings.Get(), "fontScale", "F");
+  caption_settings->font_size =
+      GetClosestFontSizePercentage(100.0 * font_scale);
+  // Android's captioning API always returns a font scale of 1 (100%) if
+  // the font size has not been set. This means we have no way to check if
+  // font size is "set" vs. "unset", so we'll just return "set" every time.
+  caption_settings->font_size_state = kSbAccessibilityCaptionStateSet;
+
+  // TODO: Convert Android typeface to font family.
+  caption_settings->font_family = kSbAccessibilityCaptionFontFamilyCasual;
+  caption_settings->font_family_state = kSbAccessibilityCaptionStateUnsupported;
+
+  caption_settings->character_edge_style = AndroidEdgeTypeToSbEdgeStyle(
+      env->GetIntFieldOrAbort(j_caption_settings.Get(), "edgeType", "I"));
+  caption_settings->character_edge_style_state = BooleanToCaptionState(
+      env->GetBooleanFieldOrAbort(j_caption_settings.Get(),
+                                  "hasEdgeType", "Z"));
+
+  SetColorProperties(
+      j_caption_settings.Get(), "foregroundColor", "hasForegroundColor",
+      &caption_settings->font_color,
+      &caption_settings->font_color_state,
+      &caption_settings->font_opacity,
+      &caption_settings->font_opacity_state);
+
+  SetColorProperties(
+      j_caption_settings.Get(), "backgroundColor", "hasBackgroundColor",
+      &caption_settings->background_color,
+      &caption_settings->background_color_state,
+      &caption_settings->background_opacity,
+      &caption_settings->background_opacity_state);
+
+  SetColorProperties(
+      j_caption_settings.Get(), "windowColor", "hasWindowColor",
+      &caption_settings->window_color,
+      &caption_settings->window_color_state,
+      &caption_settings->window_opacity,
+      &caption_settings->window_opacity_state);
+
+  caption_settings->is_enabled =
+     env->GetBooleanFieldOrAbort(j_caption_settings.Get(), "isEnabled", "Z");
+  caption_settings->supports_is_enabled = true;
+  caption_settings->supports_set_enabled = false;
+
+  return true;
+}
diff --git a/src/starboard/android/shared/accessibility_get_display_settings.cc b/src/starboard/android/shared/accessibility_get_display_settings.cc
new file mode 100644
index 0000000..d3d10e7
--- /dev/null
+++ b/src/starboard/android/shared/accessibility_get_display_settings.cc
@@ -0,0 +1,37 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "starboard/accessibility.h"
+
+#include "starboard/android/shared/jni_env_ext.h"
+#include "starboard/memory.h"
+
+using starboard::android::shared::JniEnvExt;
+
+bool SbAccessibilityGetDisplaySettings(
+    SbAccessibilityDisplaySettings* out_setting) {
+  if (!out_setting ||
+      !SbMemoryIsZero(out_setting,
+                      sizeof(SbAccessibilityDisplaySettings))) {
+    return false;
+  }
+
+  JniEnvExt* env = JniEnvExt::Get();
+  out_setting->has_high_contrast_text_setting = true;
+  out_setting->is_high_contrast_text_enabled =
+      env->CallStarboardBooleanMethodOrAbort(
+          "isAccessibilityHighContrastTextEnabled", "()Z");
+
+  return true;
+}
diff --git a/src/starboard/android/shared/accessibility_get_text_to_speech_settings.cc b/src/starboard/android/shared/accessibility_get_text_to_speech_settings.cc
new file mode 100644
index 0000000..8f64452
--- /dev/null
+++ b/src/starboard/android/shared/accessibility_get_text_to_speech_settings.cc
@@ -0,0 +1,44 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "starboard/accessibility.h"
+
+#include "starboard/android/shared/jni_env_ext.h"
+#include "starboard/android/shared/jni_utils.h"
+#include "starboard/memory.h"
+
+using starboard::android::shared::JniEnvExt;
+using starboard::android::shared::ScopedLocalJavaRef;
+
+bool SbAccessibilityGetTextToSpeechSettings(
+    SbAccessibilityTextToSpeechSettings* out_setting) {
+  if (!out_setting ||
+      !SbMemoryIsZero(out_setting,
+                      sizeof(SbAccessibilityTextToSpeechSettings))) {
+    return false;
+  }
+
+  JniEnvExt* env = JniEnvExt::Get();
+
+  out_setting->has_text_to_speech_setting = true;
+  ScopedLocalJavaRef<jobject> j_tts_helper(
+      env->CallStarboardObjectMethodOrAbort(
+          "getTextToSpeechHelper",
+          "()Ldev/cobalt/coat/CobaltTextToSpeechHelper;"));
+  out_setting->is_text_to_speech_enabled =
+      env->CallBooleanMethodOrAbort(j_tts_helper.Get(),
+          "isScreenReaderEnabled", "()Z");
+
+  return true;
+}
diff --git a/src/starboard/android/shared/accessibility_set_captions_enabled.cc b/src/starboard/android/shared/accessibility_set_captions_enabled.cc
new file mode 100644
index 0000000..f39ad20
--- /dev/null
+++ b/src/starboard/android/shared/accessibility_set_captions_enabled.cc
@@ -0,0 +1,21 @@
+// Copyright 2017 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "starboard/accessibility.h"
+#include "starboard/configuration.h"
+
+bool SbAccessibilitySetCaptionsEnabled(bool enabled) {
+  SB_UNREFERENCED_PARAMETER(enabled);
+  return false;
+}
diff --git a/src/starboard/android/shared/android_main.cc b/src/starboard/android/shared/android_main.cc
new file mode 100644
index 0000000..771ad25
--- /dev/null
+++ b/src/starboard/android/shared/android_main.cc
@@ -0,0 +1,214 @@
+// Copyright 2018 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "starboard/android/shared/application_android.h"
+#include "starboard/android/shared/jni_env_ext.h"
+#include "starboard/android/shared/jni_utils.h"
+#include "starboard/android/shared/log_internal.h"
+#include "starboard/common/semaphore.h"
+#include "starboard/shared/starboard/command_line.h"
+#include "starboard/string.h"
+#include "starboard/thread.h"
+
+namespace starboard {
+namespace android {
+namespace shared {
+namespace {
+
+using ::starboard::shared::starboard::CommandLine;
+typedef ::starboard::android::shared::ApplicationAndroid::AndroidCommand
+    AndroidCommand;
+
+SbThread g_starboard_thread = kSbThreadInvalid;
+
+// Safeguard to avoid sending AndroidCommands either when there is no instance
+// of the Starboard application, or after the run loop has exited and the
+// ALooper receiving the commands is no longer being polled.
+bool g_app_running = false;
+
+std::vector<std::string> GetArgs() {
+  std::vector<std::string> args;
+  // Fake program name as args[0]
+  args.push_back(SbStringDuplicate("android_main"));
+
+  JniEnvExt* env = JniEnvExt::Get();
+
+  ScopedLocalJavaRef<jobjectArray> args_array(
+      env->CallStarboardObjectMethodOrAbort("getArgs",
+                                            "()[Ljava/lang/String;"));
+  jint argc = !args_array ? 0 : env->GetArrayLength(args_array.Get());
+
+  for (jint i = 0; i < argc; i++) {
+    ScopedLocalJavaRef<jstring> element(
+        env->GetObjectArrayElementOrAbort(args_array.Get(), i));
+    args.push_back(env->GetStringStandardUTFOrAbort(element.Get()));
+  }
+
+  return args;
+}
+
+std::string GetStartDeepLink() {
+  JniEnvExt* env = JniEnvExt::Get();
+  std::string start_url;
+
+  ScopedLocalJavaRef<jstring> j_url(env->CallStarboardObjectMethodOrAbort(
+      "getStartDeepLink", "()Ljava/lang/String;"));
+  if (j_url) {
+    start_url = env->GetStringStandardUTFOrAbort(j_url.Get());
+  }
+  return start_url;
+}
+
+void* ThreadEntryPoint(void* context) {
+  Semaphore* app_created_semaphore = static_cast<Semaphore*>(context);
+
+  ALooper* looper = ALooper_prepare(ALOOPER_PREPARE_ALLOW_NON_CALLBACKS);
+  ApplicationAndroid app(looper);
+
+  CommandLine command_line(GetArgs());
+  LogInit(command_line);
+
+  // Mark the app running before signaling app created so there's no race to
+  // allow sending the first AndroidCommand after onCreate() returns.
+  g_app_running = true;
+
+  // Signal ANativeActivity_onCreate() that it may proceed.
+  app_created_semaphore->Put();
+
+  // Enter the Starboard run loop until stopped.
+  int error_level =
+      app.Run(std::move(command_line), GetStartDeepLink().c_str());
+
+  // Mark the app not running before informing StarboardBridge that the app is
+  // stopped so that we won't send any more AndroidCommands as a result of
+  // shutting down the Activity.
+  g_app_running = false;
+
+  // Our launcher.py looks for this to know when the app (test) is done.
+  SB_LOG(INFO) << "***Application Stopped*** " << error_level;
+
+  // Inform StarboardBridge that the run loop has exited so it can cleanup and
+  // kill the process.
+  JniEnvExt* env = JniEnvExt::Get();
+  env->CallStarboardVoidMethodOrAbort("afterStopped", "()V");
+
+  return NULL;
+}
+
+void OnStart(ANativeActivity* activity) {
+  if (g_app_running) {
+    ApplicationAndroid::Get()->SendAndroidCommand(AndroidCommand::kStart);
+  }
+}
+
+void OnResume(ANativeActivity* activity) {
+  if (g_app_running) {
+    ApplicationAndroid::Get()->SendAndroidCommand(AndroidCommand::kResume);
+  }
+}
+
+void OnPause(ANativeActivity* activity) {
+  if (g_app_running) {
+    ApplicationAndroid::Get()->SendAndroidCommand(AndroidCommand::kPause);
+  }
+}
+
+void OnStop(ANativeActivity* activity) {
+  if (g_app_running) {
+    ApplicationAndroid::Get()->SendAndroidCommand(AndroidCommand::kStop);
+  }
+}
+
+void OnWindowFocusChanged(ANativeActivity* activity, int focused) {
+  if (g_app_running) {
+    ApplicationAndroid::Get()->SendAndroidCommand(focused
+        ? AndroidCommand::kWindowFocusGained
+        : AndroidCommand::kWindowFocusLost);
+  }
+}
+
+void OnNativeWindowCreated(ANativeActivity* activity, ANativeWindow* window) {
+  if (g_app_running) {
+    ApplicationAndroid::Get()->SendAndroidCommand(
+        AndroidCommand::kNativeWindowCreated, window);
+  }
+}
+
+void OnNativeWindowDestroyed(ANativeActivity* activity, ANativeWindow* window) {
+  if (g_app_running) {
+    ApplicationAndroid::Get()->SendAndroidCommand(
+        AndroidCommand::kNativeWindowDestroyed);
+  }
+}
+
+void OnInputQueueCreated(ANativeActivity* activity, AInputQueue* queue) {
+  if (g_app_running) {
+    ApplicationAndroid::Get()->SendAndroidCommand(
+        AndroidCommand::kInputQueueChanged, queue);
+  }
+}
+
+void OnInputQueueDestroyed(ANativeActivity* activity, AInputQueue* queue) {
+  if (g_app_running) {
+    ApplicationAndroid::Get()->SendAndroidCommand(
+        AndroidCommand::kInputQueueChanged, NULL);
+  }
+}
+
+extern "C" SB_EXPORT_PLATFORM void ANativeActivity_onCreate(
+    ANativeActivity *activity, void *savedState, size_t savedStateSize) {
+
+  // Start the Starboard thread the first time an Activity is created.
+  if (!SbThreadIsValid(g_starboard_thread)) {
+    Semaphore semaphore;
+
+    g_starboard_thread = SbThreadCreate(
+        0, kSbThreadPriorityNormal, kSbThreadNoAffinity, false,
+        "StarboardMain", &ThreadEntryPoint, &semaphore);
+
+    // Wait for the ApplicationAndroid to be created.
+    semaphore.Take();
+  }
+
+  activity->callbacks->onStart = OnStart;
+  activity->callbacks->onResume = OnResume;
+  activity->callbacks->onPause = OnPause;
+  activity->callbacks->onStop = OnStop;
+  activity->callbacks->onWindowFocusChanged = OnWindowFocusChanged;
+  activity->callbacks->onNativeWindowCreated = OnNativeWindowCreated;
+  activity->callbacks->onNativeWindowDestroyed = OnNativeWindowDestroyed;
+  activity->callbacks->onInputQueueCreated = OnInputQueueCreated;
+  activity->callbacks->onInputQueueDestroyed = OnInputQueueDestroyed;
+  activity->instance = ApplicationAndroid::Get();
+}
+
+extern "C" SB_EXPORT_PLATFORM
+jboolean Java_dev_cobalt_coat_StarboardBridge_nativeIsReleaseBuild() {
+#if defined(COBALT_BUILD_TYPE_GOLD)
+  return true;
+#else
+  return false;
+#endif
+}
+
+extern "C" SB_EXPORT_PLATFORM
+void Java_dev_cobalt_coat_StarboardBridge_nativeInitialize(
+    JniEnvExt* env, jobject starboard_bridge) {
+  JniEnvExt::Initialize(env, starboard_bridge);
+}
+
+}  // namespace
+}  // namespace shared
+}  // namespace android
+}  // namespace starboard
diff --git a/src/starboard/android/shared/application_android.cc b/src/starboard/android/shared/application_android.cc
new file mode 100644
index 0000000..5420592
--- /dev/null
+++ b/src/starboard/android/shared/application_android.cc
@@ -0,0 +1,442 @@
+// Copyright 2016 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "starboard/android/shared/application_android.h"
+
+#include <android/looper.h>
+#include <android/native_activity.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <string>
+#include <vector>
+
+#include "starboard/accessibility.h"
+#include "starboard/android/shared/file_internal.h"
+#include "starboard/android/shared/input_events_generator.h"
+#include "starboard/android/shared/jni_env_ext.h"
+#include "starboard/android/shared/jni_utils.h"
+#include "starboard/android/shared/window_internal.h"
+#include "starboard/condition_variable.h"
+#include "starboard/event.h"
+#include "starboard/log.h"
+#include "starboard/mutex.h"
+#include "starboard/shared/starboard/audio_sink/audio_sink_internal.h"
+#include "starboard/string.h"
+
+namespace starboard {
+namespace android {
+namespace shared {
+
+namespace {
+  enum {
+    kLooperIdAndroidCommand,
+    kLooperIdAndroidInput,
+    kLooperIdKeyboardInject,
+  };
+
+  const char* AndroidCommandName(
+      ApplicationAndroid::AndroidCommand::CommandType type) {
+    switch (type) {
+      case ApplicationAndroid::AndroidCommand::kUndefined:
+        return "Undefined";
+      case ApplicationAndroid::AndroidCommand::kStart:
+        return "Start";
+      case ApplicationAndroid::AndroidCommand::kResume:
+        return "Resume";
+      case ApplicationAndroid::AndroidCommand::kPause:
+        return "Pause";
+      case ApplicationAndroid::AndroidCommand::kStop:
+        return "Stop";
+      case ApplicationAndroid::AndroidCommand::kInputQueueChanged:
+        return "InputQueueChanged";
+      case ApplicationAndroid::AndroidCommand::kNativeWindowCreated:
+        return "NativeWindowCreated";
+      case ApplicationAndroid::AndroidCommand::kNativeWindowDestroyed:
+        return "NativeWindowDestroyed";
+      case ApplicationAndroid::AndroidCommand::kWindowFocusGained:
+        return "WindowFocusGained";
+      case ApplicationAndroid::AndroidCommand::kWindowFocusLost:
+        return "WindowFocusLost";
+      default:
+        return "unknown";
+    }
+  }
+}  // namespace
+
+// "using" doesn't work with class members, so make a local convenience type.
+typedef ::starboard::shared::starboard::Application::Event Event;
+
+ApplicationAndroid::ApplicationAndroid(ALooper* looper)
+    : looper_(looper),
+      native_window_(NULL),
+      input_queue_(NULL),
+      android_command_readfd_(-1),
+      android_command_writefd_(-1),
+      keyboard_inject_readfd_(-1),
+      keyboard_inject_writefd_(-1),
+      android_command_condition_(android_command_mutex_),
+      activity_state_(AndroidCommand::kUndefined),
+      window_(kSbWindowInvalid),
+      last_is_accessibility_high_contrast_text_enabled_(false) {
+
+  // Initialize Time Zone early so that local time works correctly.
+  // Called once here to help SbTimeZoneGet*Name()
+  tzset();
+
+  // Initialize Android asset access early so that ICU can load its tables
+  // from the assets. The use ICU is used in our logging.
+  SbFileAndroidInitialize();
+
+  int pipefd[2];
+  int err;
+
+  err = pipe(pipefd);
+  SB_CHECK(err >= 0) << "pipe errno is:" << errno;
+  android_command_readfd_ = pipefd[0];
+  android_command_writefd_ = pipefd[1];
+  ALooper_addFd(looper_, android_command_readfd_, kLooperIdAndroidCommand,
+                ALOOPER_EVENT_INPUT, NULL, NULL);
+
+  err = pipe(pipefd);
+  SB_CHECK(err >= 0) << "pipe errno is:" << errno;
+  keyboard_inject_readfd_ = pipefd[0];
+  keyboard_inject_writefd_ = pipefd[1];
+  ALooper_addFd(looper_, keyboard_inject_readfd_, kLooperIdKeyboardInject,
+                ALOOPER_EVENT_INPUT, NULL, NULL);
+}
+
+ApplicationAndroid::~ApplicationAndroid() {
+  ALooper_removeFd(looper_, android_command_readfd_);
+  close(android_command_readfd_);
+  close(android_command_writefd_);
+
+  ALooper_removeFd(looper_, keyboard_inject_readfd_);
+  close(keyboard_inject_readfd_);
+  close(keyboard_inject_writefd_);
+}
+
+void ApplicationAndroid::Initialize() {
+  SbAudioSinkPrivate::Initialize();
+}
+
+void ApplicationAndroid::Teardown() {
+  SbAudioSinkPrivate::TearDown();
+  SbFileAndroidTeardown();
+}
+
+SbWindow ApplicationAndroid::CreateWindow(const SbWindowOptions* options) {
+  SB_UNREFERENCED_PARAMETER(options);
+  if (SbWindowIsValid(window_)) {
+    return kSbWindowInvalid;
+  }
+  window_ = new SbWindowPrivate;
+  window_->native_window = native_window_;
+  input_events_generator_.reset(new InputEventsGenerator(window_));
+  return window_;
+}
+
+bool ApplicationAndroid::DestroyWindow(SbWindow window) {
+  if (!SbWindowIsValid(window)) {
+    return false;
+  }
+
+  input_events_generator_.reset();
+
+  SB_DCHECK(window == window_);
+  delete window_;
+  window_ = kSbWindowInvalid;
+  return true;
+}
+
+Event* ApplicationAndroid::WaitForSystemEventWithTimeout(SbTime time) {
+  // Convert from microseconds to milliseconds, taking the ceiling value.
+  // If we take the floor, or round, then we end up busy looping every time
+  // the next event time is less than one millisecond.
+  int timeout_millis = (time + kSbTimeMillisecond - 1) / kSbTimeMillisecond;
+  int looper_events;
+  int ident = ALooper_pollAll(timeout_millis, NULL, &looper_events, NULL);
+  switch (ident) {
+    case kLooperIdAndroidCommand:
+      ProcessAndroidCommand();
+      break;
+    case kLooperIdAndroidInput:
+      ProcessAndroidInput();
+      break;
+    case kLooperIdKeyboardInject:
+      ProcessKeyboardInject();
+      break;
+  }
+
+  // Always return NULL since we already dispatched our own system events.
+  return NULL;
+}
+
+void ApplicationAndroid::WakeSystemEventWait() {
+  ALooper_wake(looper_);
+}
+
+void ApplicationAndroid::OnResume() {
+  JniEnvExt* env = JniEnvExt::Get();
+  env->CallStarboardVoidMethodOrAbort("beforeStartOrResume", "()V");
+}
+
+void ApplicationAndroid::ProcessAndroidCommand() {
+  JniEnvExt* env = JniEnvExt::Get();
+  AndroidCommand cmd;
+  int err = read(android_command_readfd_, &cmd, sizeof(cmd));
+  SB_DCHECK(err >= 0) << "Command read failed. errno=" << errno;
+
+  SB_LOG(INFO) << "Android command: " << AndroidCommandName(cmd.type);
+
+  // The activity state to which we should sync the starboard state.
+  AndroidCommand::CommandType sync_state = AndroidCommand::kUndefined;
+
+  switch (cmd.type) {
+    case AndroidCommand::kUndefined:
+      break;
+
+    case AndroidCommand::kInputQueueChanged: {
+      ScopedLock lock(android_command_mutex_);
+      if (input_queue_) {
+        AInputQueue_detachLooper(input_queue_);
+      }
+      input_queue_ = static_cast<AInputQueue*>(cmd.data);
+      if (input_queue_) {
+        AInputQueue_attachLooper(input_queue_, looper_, kLooperIdAndroidInput,
+                                 NULL, NULL);
+      }
+      // Now that we've swapped our use of the input queue, signal that the
+      // Android UI thread can continue.
+      android_command_condition_.Signal();
+      break;
+    }
+
+    // Starboard resume/suspend is tied to the UI window being created/destroyed
+    // (rather than to the Activity lifecycle) since Cobalt can't do anything at
+    // all if it doesn't have a window surface to draw on.
+    case AndroidCommand::kNativeWindowCreated:
+      {
+        ScopedLock lock(android_command_mutex_);
+        native_window_ = static_cast<ANativeWindow*>(cmd.data);
+        if (window_) {
+          window_->native_window = native_window_;
+        }
+        // Now that we have the window, signal that the Android UI thread can
+        // continue, before we start or resume the Starboard app.
+        android_command_condition_.Signal();
+      }
+      if (state() == kStateUnstarted) {
+        // This is the initial launch, so we have to start Cobalt now that we
+        // have a window.
+        env->CallStarboardVoidMethodOrAbort("beforeStartOrResume", "()V");
+        DispatchStart();
+      } else {
+        // Now that we got a window back, change the command for the switch
+        // below to sync up with the current activity lifecycle.
+        sync_state = activity_state_;
+      }
+      break;
+    case AndroidCommand::kNativeWindowDestroyed:
+      env->CallStarboardVoidMethodOrAbort("beforeSuspend", "()V");
+      {
+        ScopedLock lock(android_command_mutex_);
+        // Cobalt can't keep running without a window, even if the Activity
+        // hasn't stopped yet. DispatchAndDelete() will inject events as needed
+        // if we're not already paused.
+        DispatchAndDelete(new Event(kSbEventTypeSuspend, NULL, NULL));
+        if (window_) {
+          window_->native_window = NULL;
+        }
+        native_window_ = NULL;
+        // Now that we've suspended the Starboard app, and let go of the window,
+        // signal that the Android UI thread can continue.
+        android_command_condition_.Signal();
+      }
+      break;
+
+    case AndroidCommand::kWindowFocusLost:
+      break;
+    case AndroidCommand::kWindowFocusGained: {
+      // Android does not have a publicly-exposed way to
+      // register for high-contrast text settings changed events.
+      // We assume that it can only change when our focus changes
+      // (because the user exits and enters the app) so we check
+      // for changes here.
+      SbAccessibilityDisplaySettings settings;
+      SbMemorySet(&settings, 0, sizeof(settings));
+      if (!SbAccessibilityGetDisplaySettings(&settings)) {
+        break;
+      }
+
+      bool enabled = settings.has_high_contrast_text_setting &&
+          settings.is_high_contrast_text_enabled;
+
+      if (enabled != last_is_accessibility_high_contrast_text_enabled_) {
+        DispatchAndDelete(new Event(
+            kSbEventTypeAccessiblitySettingsChanged, NULL, NULL));
+      }
+      last_is_accessibility_high_contrast_text_enabled_ = enabled;
+      break;
+    }
+
+    // Remember the Android activity state to sync to when we have a window.
+    case AndroidCommand::kStart:
+    case AndroidCommand::kResume:
+    case AndroidCommand::kPause:
+    case AndroidCommand::kStop:
+      sync_state = activity_state_ = cmd.type;
+      break;
+  }
+
+  // If there's a window, sync the app state to the Activity lifecycle, letting
+  // DispatchAndDelete() inject events as needed if we missed a state.
+  if (native_window_) {
+    switch (sync_state) {
+      case AndroidCommand::kStart:
+        DispatchAndDelete(new Event(kSbEventTypeResume, NULL, NULL));
+        break;
+      case AndroidCommand::kResume:
+        DispatchAndDelete(new Event(kSbEventTypeUnpause, NULL, NULL));
+        break;
+      case AndroidCommand::kPause:
+        DispatchAndDelete(new Event(kSbEventTypePause, NULL, NULL));
+        break;
+      case AndroidCommand::kStop:
+        if (state() != kStateSuspended) {
+          // We usually suspend when losing the window above, but if the window
+          // wasn't destroyed (e.g. when Daydream starts) then we still have to
+          // suspend when the Activity is stopped.
+          env->CallStarboardVoidMethodOrAbort("beforeSuspend", "()V");
+          DispatchAndDelete(new Event(kSbEventTypeSuspend, NULL, NULL));
+        }
+        break;
+      default:
+        break;
+    }
+  }
+}
+
+void ApplicationAndroid::SendAndroidCommand(AndroidCommand::CommandType type,
+                                            void* data) {
+  SB_LOG(INFO) << "Send Android command: " << AndroidCommandName(type);
+  AndroidCommand cmd {type, data};
+  ScopedLock lock(android_command_mutex_);
+  write(android_command_writefd_, &cmd, sizeof(cmd));
+  // Synchronization only necessary when managing resources.
+  switch (type) {
+    case AndroidCommand::kInputQueueChanged:
+      while (input_queue_ != data) {
+        android_command_condition_.Wait();
+      }
+      break;
+    case AndroidCommand::kNativeWindowCreated:
+    case AndroidCommand::kNativeWindowDestroyed:
+      while (native_window_ != data) {
+        android_command_condition_.Wait();
+      }
+      break;
+    default:
+      break;
+  }
+}
+
+void ApplicationAndroid::ProcessAndroidInput() {
+  SB_DCHECK(input_events_generator_);
+  AInputEvent* android_event = NULL;
+  while (AInputQueue_getEvent(input_queue_, &android_event) >= 0) {
+    SB_LOG(INFO) << "Android input: type="
+                 << AInputEvent_getType(android_event);
+    if (AInputQueue_preDispatchEvent(input_queue_, android_event)) {
+        continue;
+    }
+    InputEventsGenerator::Events app_events;
+    bool handled = input_events_generator_->CreateInputEventsFromAndroidEvent(
+        android_event, &app_events);
+    for (int i = 0; i < app_events.size(); ++i) {
+      DispatchAndDelete(app_events[i].release());
+    }
+    AInputQueue_finishEvent(input_queue_, android_event, handled);
+  }
+}
+
+void ApplicationAndroid::ProcessKeyboardInject() {
+  SbKey key;
+  int err = read(keyboard_inject_readfd_, &key, sizeof(key));
+  SB_DCHECK(err >= 0) << "Keyboard inject read failed: errno=" << errno;
+  SB_LOG(INFO) << "Keyboard inject: " << key;
+
+  InputEventsGenerator::Events app_events;
+  input_events_generator_->CreateInputEventsFromSbKey(key, &app_events);
+  for (int i = 0; i < app_events.size(); ++i) {
+    DispatchAndDelete(app_events[i].release());
+  }
+}
+
+void ApplicationAndroid::SendKeyboardInject(SbKey key) {
+  write(keyboard_inject_writefd_, &key, sizeof(key));
+}
+
+extern "C" SB_EXPORT_PLATFORM void
+Java_dev_cobalt_coat_CobaltA11yHelper_nativeInjectKeyEvent(JNIEnv* env,
+                                                           jobject unused_clazz,
+                                                           jint key) {
+  ApplicationAndroid::Get()->SendKeyboardInject(static_cast<SbKey>(key));
+}
+
+bool ApplicationAndroid::OnSearchRequested() {
+  for (int i = 0; i < 2; i++) {
+    SbInputData* data = new SbInputData();
+    SbMemorySet(data, 0, sizeof(*data));
+    data->window = window_;
+    data->key = kSbKeyBrowserSearch;
+    data->type = (i == 0) ? kSbInputEventTypePress : kSbInputEventTypeUnpress;
+    Inject(new Event(kSbEventTypeInput, data, &DeleteDestructor<SbInputData>));
+  }
+  return true;
+}
+
+extern "C" SB_EXPORT_PLATFORM
+jboolean Java_dev_cobalt_coat_StarboardBridge_nativeOnSearchRequested(
+    JniEnvExt* env, jobject unused_this) {
+  return ApplicationAndroid::Get()->OnSearchRequested();
+}
+
+void ApplicationAndroid::HandleDeepLink(const char* link_url) {
+  if (link_url == NULL || link_url[0] == '\0') {
+    return;
+  }
+  char* deep_link = SbStringDuplicate(link_url);
+  SB_DCHECK(deep_link);
+  Inject(new Event(kSbEventTypeLink, deep_link, SbMemoryDeallocate));
+}
+
+extern "C" SB_EXPORT_PLATFORM
+void Java_dev_cobalt_coat_StarboardBridge_nativeHandleDeepLink(
+    JniEnvExt* env, jobject unused_this, jstring j_url) {
+  if (j_url) {
+    std::string utf_str = env->GetStringStandardUTFOrAbort(j_url);
+    ApplicationAndroid::Get()->HandleDeepLink(utf_str.c_str());
+  }
+}
+
+extern "C" SB_EXPORT_PLATFORM
+void Java_dev_cobalt_coat_StarboardBridge_nativeStopApp(
+    JniEnvExt* env, jobject unused_this, jint error_level) {
+  ApplicationAndroid::Get()->Stop(error_level);
+}
+
+}  // namespace shared
+}  // namespace android
+}  // namespace starboard
diff --git a/src/starboard/android/shared/application_android.h b/src/starboard/android/shared/application_android.h
new file mode 100644
index 0000000..011ddcf
--- /dev/null
+++ b/src/starboard/android/shared/application_android.h
@@ -0,0 +1,124 @@
+// Copyright 2016 The Cobalt Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef STARBOARD_ANDROID_SHARED_APPLICATION_ANDROID_H_
+#define STARBOARD_ANDROID_SHARED_APPLICATION_ANDROID_H_
+
+#include <android/looper.h>
+#include <android/native_window.h>
+
+#include "starboard/android/shared/input_events_generator.h"
+#include "starboard/common/scoped_ptr.h"
+#include "starboard/condition_variable.h"
+#include "starboard/configuration.h"
+#include "starboard/mutex.h"
+#include "starboard/shared/internal_only.h"
+#include "starboard/shared/starboard/application.h"
+#include "starboard/shared/starboard/queue_application.h"
+#include "starboard/types.h"
+
+namespace starboard {
+namespace android {
+namespace shared {
+
+// Android application receiving commands and input through ALooper.
+class ApplicationAndroid
+    : public ::starboard::shared::starboard::QueueApplication {
+ public:
+  struct AndroidCommand {
+    typedef enum {
+      kUndefined,
+      kStart,
+      kResume,
+      kPause,
+      kStop,
+      kInputQueueChanged,
+      kNativeWindowCreated,
+      kNativeWindowDestroyed,
+      kWindowFocusGained,
+      kWindowFocusLost,
+    } CommandType;
+
+    CommandType t